feat(mcp): harden remote assets and job execution
Gate source-file downloads and verify registry paths and content hashes. Stream multipart uploads with bounded limits, supervise OpenCode jobs with explicit result markers and timeouts, and preserve managed endpoint security settings. Document the trusted-client boundary and add focused regression coverage. Validation: PYTHONPATH=<staged-tree> python -m unittest discover -s tests (182 tests)
This commit is contained in:
+211
-12
@@ -1,8 +1,11 @@
|
||||
import hashlib
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from obsidian_wiki.access_control import source_decision as actual_source_decision
|
||||
from obsidian_wiki.file_assets import connect, init_db, upsert_asset
|
||||
from obsidian_wiki.mcp_assets import (
|
||||
AssetAccessError,
|
||||
@@ -10,7 +13,9 @@ from obsidian_wiki.mcp_assets import (
|
||||
asset_uri_for_file_id,
|
||||
file_id_from_asset_uri,
|
||||
get_file_asset_metadata,
|
||||
mcp_runtime_options,
|
||||
read_file_asset_resource,
|
||||
record_allowed_for_mcp,
|
||||
search_file_assets,
|
||||
)
|
||||
|
||||
@@ -23,13 +28,11 @@ class McpAssetTests(unittest.TestCase):
|
||||
self.db_path = self.vault / "_meta" / "file-assets.sqlite"
|
||||
self.registry_path = self.vault / "_meta" / "source-registry.jsonl"
|
||||
init_db(self.db_path)
|
||||
self._write_profile_file()
|
||||
|
||||
self.drawing_id = "sha256:" + "a" * 64
|
||||
self.order_id = "sha256:" + "b" * 64
|
||||
self.complaint_id = "sha256:" + "c" * 64
|
||||
self._write_asset_file("drawings/a.pdf", b"%PDF allowed drawing")
|
||||
self._write_asset_file("orders/order.xlsx", b"customer price data")
|
||||
self._write_asset_file("quality/photo.png", b"complaint evidence")
|
||||
self.drawing_id = self._write_asset_file("drawings/a.pdf", b"%PDF allowed drawing")
|
||||
self.order_id = self._write_asset_file("orders/order.xlsx", b"customer price data")
|
||||
self.complaint_id = self._write_asset_file("quality/photo.png", b"complaint evidence")
|
||||
|
||||
self._insert_asset(
|
||||
self.drawing_id,
|
||||
@@ -46,6 +49,7 @@ class McpAssetTests(unittest.TestCase):
|
||||
series=["0.5-18"],
|
||||
pitch=["0.5"],
|
||||
pin_count=["18"],
|
||||
allowed_bots=["bjd-general-bot", "missing-bot"],
|
||||
)
|
||||
self._insert_asset(
|
||||
self.order_id,
|
||||
@@ -77,6 +81,7 @@ class McpAssetTests(unittest.TestCase):
|
||||
path = self.vault / "_sources" / "archive" / rel_path
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(data)
|
||||
return "sha256:" + hashlib.sha256(data).hexdigest()
|
||||
|
||||
def _insert_asset(self, file_id, **fields):
|
||||
record = {
|
||||
@@ -142,7 +147,17 @@ class McpAssetTests(unittest.TestCase):
|
||||
"allowed_business_domains": ["products", "engineering"],
|
||||
"blocked_doc_types": ["order_data", "evidence_media"],
|
||||
"include_source_files": False,
|
||||
}
|
||||
},
|
||||
"public-product-bot": {
|
||||
"allowed_visibility": ["public", "internal"],
|
||||
"allowed_data_classification": [
|
||||
"public",
|
||||
"internal",
|
||||
"technical-confidential",
|
||||
],
|
||||
"allowed_business_domains": ["products", "engineering"],
|
||||
"include_source_files": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
(self.vault / "_meta" / "bot-profiles.json").write_text(json.dumps(data), encoding="utf-8")
|
||||
@@ -164,9 +179,10 @@ class McpAssetTests(unittest.TestCase):
|
||||
self.assertEqual(rows[0]["pin_count"], ["18"])
|
||||
|
||||
def test_customer_shareable_engineering_files_work_with_source_file_profile(self):
|
||||
self._write_profile_file()
|
||||
shareable_id = "sha256:" + "d" * 64
|
||||
self._write_asset_file("drawings/customer-shareable.pdf", b"%PDF customer drawing")
|
||||
shareable_id = self._write_asset_file(
|
||||
"drawings/customer-shareable.pdf",
|
||||
b"%PDF customer drawing",
|
||||
)
|
||||
self._insert_asset(
|
||||
shareable_id,
|
||||
archive_path="_sources/archive/drawings/customer-shareable.pdf",
|
||||
@@ -193,7 +209,10 @@ class McpAssetTests(unittest.TestCase):
|
||||
def test_asset_uri_round_trips_file_id(self):
|
||||
uri = asset_uri_for_file_id(self.drawing_id)
|
||||
|
||||
self.assertEqual(uri, "asset://source/sha256/" + "a" * 64)
|
||||
self.assertEqual(
|
||||
uri,
|
||||
"asset://source/sha256/" + self.drawing_id.removeprefix("sha256:"),
|
||||
)
|
||||
self.assertEqual(file_id_from_asset_uri(uri), self.drawing_id)
|
||||
|
||||
def test_resource_read_rechecks_acl_and_returns_file_bytes(self):
|
||||
@@ -201,6 +220,7 @@ class McpAssetTests(unittest.TestCase):
|
||||
self.vault,
|
||||
asset_uri_for_file_id(self.drawing_id),
|
||||
db_path=self.db_path,
|
||||
allow_source_files=True,
|
||||
)
|
||||
|
||||
self.assertEqual(data["name"], "B3501_11101c_X2.pdf")
|
||||
@@ -213,6 +233,7 @@ class McpAssetTests(unittest.TestCase):
|
||||
self.vault,
|
||||
asset_uri_for_file_id(self.order_id),
|
||||
db_path=self.db_path,
|
||||
allow_source_files=True,
|
||||
)
|
||||
|
||||
def test_resource_read_enforces_max_size(self):
|
||||
@@ -222,6 +243,67 @@ class McpAssetTests(unittest.TestCase):
|
||||
asset_uri_for_file_id(self.drawing_id),
|
||||
db_path=self.db_path,
|
||||
max_file_bytes=5,
|
||||
allow_source_files=True,
|
||||
)
|
||||
|
||||
def test_resource_read_requires_explicit_server_opt_in(self):
|
||||
with self.assertRaisesRegex(AssetAccessError, "source file reads are disabled"):
|
||||
read_file_asset_resource(
|
||||
self.vault,
|
||||
asset_uri_for_file_id(self.drawing_id),
|
||||
db_path=self.db_path,
|
||||
)
|
||||
|
||||
def test_runtime_options_source_file_gate_defaults_closed_and_reads_env(self):
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
self.assertFalse(mcp_runtime_options()["allow_source_files"])
|
||||
with patch.dict("os.environ", {"OBSIDIAN_MCP_ALLOW_SOURCE_FILES": "1"}, clear=True):
|
||||
self.assertTrue(mcp_runtime_options()["allow_source_files"])
|
||||
|
||||
def test_resource_read_rejects_db_registry_archive_path_mismatch(self):
|
||||
other_path = "_sources/archive/drawings/other.pdf"
|
||||
self._write_asset_file("drawings/other.pdf", b"%PDF other drawing")
|
||||
with connect(self.db_path) as conn:
|
||||
conn.execute(
|
||||
"UPDATE file_assets SET archive_path = ? WHERE file_id = ?",
|
||||
(other_path, self.drawing_id),
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(AssetAccessError, "archive_path mismatch"):
|
||||
read_file_asset_resource(
|
||||
self.vault,
|
||||
asset_uri_for_file_id(self.drawing_id),
|
||||
db_path=self.db_path,
|
||||
allow_source_files=True,
|
||||
)
|
||||
|
||||
def test_resource_read_accepts_normalization_equivalent_archive_paths(self):
|
||||
with connect(self.db_path) as conn:
|
||||
conn.execute(
|
||||
"UPDATE file_assets SET archive_path = ? WHERE file_id = ?",
|
||||
("_sources/archive/drawings/../drawings/a.pdf", self.drawing_id),
|
||||
)
|
||||
|
||||
data = read_file_asset_resource(
|
||||
self.vault,
|
||||
asset_uri_for_file_id(self.drawing_id),
|
||||
db_path=self.db_path,
|
||||
allow_source_files=True,
|
||||
)
|
||||
|
||||
self.assertEqual(data["bytes"], b"%PDF allowed drawing")
|
||||
|
||||
def test_resource_read_rejects_content_that_no_longer_matches_file_id(self):
|
||||
(self.vault / "_sources" / "archive" / "drawings" / "a.pdf").write_bytes(
|
||||
b"%PDF tampered drawing"
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(AssetAccessError, "content hash mismatch"):
|
||||
read_file_asset_resource(
|
||||
self.vault,
|
||||
asset_uri_for_file_id(self.drawing_id),
|
||||
db_path=self.db_path,
|
||||
allow_source_files=True,
|
||||
)
|
||||
|
||||
def test_metadata_uses_safe_fields(self):
|
||||
@@ -232,7 +314,7 @@ class McpAssetTests(unittest.TestCase):
|
||||
self.assertEqual(metadata["resource_uri"], asset_uri_for_file_id(self.drawing_id))
|
||||
self.assertNotIn("absolute_path", metadata)
|
||||
|
||||
def test_registry_allowed_bots_are_enforced_without_profile_file(self):
|
||||
def test_registry_allowed_bots_are_enforced_with_valid_profile(self):
|
||||
with self.assertRaises(AssetAccessError):
|
||||
get_file_asset_metadata(
|
||||
self.vault,
|
||||
@@ -241,6 +323,123 @@ class McpAssetTests(unittest.TestCase):
|
||||
profile_name="public-product-bot",
|
||||
)
|
||||
|
||||
def test_missing_requested_profile_fails_closed(self):
|
||||
with self.assertRaisesRegex(AssetAccessError, "bot profile not found"):
|
||||
search_file_assets(
|
||||
self.vault,
|
||||
"B3501",
|
||||
db_path=self.db_path,
|
||||
profile_name="missing-bot",
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(AssetAccessError, "bot profile not found"):
|
||||
get_file_asset_metadata(
|
||||
self.vault,
|
||||
self.drawing_id,
|
||||
db_path=self.db_path,
|
||||
profile_name="missing-bot",
|
||||
)
|
||||
|
||||
def test_metadata_acl_does_not_rewrite_profile_source_file_setting(self):
|
||||
profile = {
|
||||
"allowed_visibility": ["internal"],
|
||||
"allowed_data_classification": ["technical-confidential"],
|
||||
"allowed_business_domains": ["products", "engineering"],
|
||||
"include_source_files": False,
|
||||
}
|
||||
record = {
|
||||
"archive_path": "_sources/archive/drawings/a.pdf",
|
||||
"doc_type": "drawing",
|
||||
"visibility": "internal",
|
||||
"data_classification": "customer-shareable",
|
||||
"business_categories": ["products/btb", "drawings/customer"],
|
||||
"allowed_bots": ["bjd-general-bot"],
|
||||
}
|
||||
|
||||
with patch(
|
||||
"obsidian_wiki.mcp_assets.source_decision",
|
||||
wraps=actual_source_decision,
|
||||
) as mocked_decision:
|
||||
allowed, reason = record_allowed_for_mcp(
|
||||
record,
|
||||
profile_name="bjd-general-bot",
|
||||
profile=profile,
|
||||
)
|
||||
|
||||
self.assertTrue(allowed, reason)
|
||||
runtime_profile = mocked_decision.call_args.args[1]
|
||||
self.assertIsNot(runtime_profile, profile)
|
||||
self.assertTrue(runtime_profile["include_source_files"])
|
||||
self.assertIn("customer-shareable", runtime_profile["allowed_data_classification"])
|
||||
self.assertFalse(profile["include_source_files"])
|
||||
self.assertNotIn("customer-shareable", profile["allowed_data_classification"])
|
||||
|
||||
def test_search_filters_acl_before_applying_limit(self):
|
||||
allowed_id = self._write_asset_file(
|
||||
"drawings/allowed-limit.pdf",
|
||||
b"%PDF allowed lower-ranked limit result",
|
||||
)
|
||||
self._insert_asset(
|
||||
allowed_id,
|
||||
archive_path="_sources/archive/drawings/allowed-limit.pdf",
|
||||
original_name="Allowed limit drawing.pdf",
|
||||
doc_type="drawing",
|
||||
source_type="pdf",
|
||||
product_lines=["BTB"],
|
||||
business_categories=["products/btb"],
|
||||
data_classification="technical-confidential",
|
||||
visibility="internal",
|
||||
summary="BLOCKME appears only as unstructured text.",
|
||||
)
|
||||
for index in range(4):
|
||||
blocked_id = self._write_asset_file(
|
||||
f"drawings/blocked-limit-{index}.pdf",
|
||||
f"%PDF blocked limit result {index}".encode(),
|
||||
)
|
||||
self._insert_asset(
|
||||
blocked_id,
|
||||
archive_path=f"_sources/archive/drawings/blocked-limit-{index}.pdf",
|
||||
original_name=f"Blocked limit drawing {index}.pdf",
|
||||
doc_type="drawing",
|
||||
source_type="pdf",
|
||||
product_lines=["BTB"],
|
||||
business_categories=["products/btb"],
|
||||
data_classification="technical-confidential",
|
||||
visibility="internal",
|
||||
summary="BLOCKME structured identifier result.",
|
||||
bjd_part_numbers=["BLOCKME"],
|
||||
allowed_bots=["other-bot"],
|
||||
)
|
||||
|
||||
rows = search_file_assets(self.vault, "BLOCKME", db_path=self.db_path, limit=1)
|
||||
|
||||
self.assertEqual([row["file_id"] for row in rows], [allowed_id])
|
||||
|
||||
def test_search_clamps_limit_to_public_range(self):
|
||||
for index in range(101):
|
||||
file_id = self._write_asset_file(
|
||||
f"drawings/bulk-{index}.pdf",
|
||||
f"%PDF BULK-LIMIT {index}".encode(),
|
||||
)
|
||||
self._insert_asset(
|
||||
file_id,
|
||||
archive_path=f"_sources/archive/drawings/bulk-{index}.pdf",
|
||||
original_name=f"Bulk limit drawing {index}.pdf",
|
||||
doc_type="drawing",
|
||||
source_type="pdf",
|
||||
product_lines=["BTB"],
|
||||
business_categories=["products/btb"],
|
||||
data_classification="technical-confidential",
|
||||
visibility="internal",
|
||||
summary="BULK-LIMIT clamp candidate.",
|
||||
)
|
||||
|
||||
lower = search_file_assets(self.vault, "B3501", db_path=self.db_path, limit=0)
|
||||
upper = search_file_assets(self.vault, "BULK-LIMIT", db_path=self.db_path, limit=1000)
|
||||
|
||||
self.assertEqual(len(lower), 1)
|
||||
self.assertEqual(len(upper), 100)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
@@ -35,6 +36,7 @@ class McpIngestTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
self.manager.close()
|
||||
self.tempdir.cleanup()
|
||||
|
||||
def test_parse_bool_accepts_enabled_values_only(self):
|
||||
@@ -45,6 +47,18 @@ class McpIngestTests(unittest.TestCase):
|
||||
self.assertFalse(parse_bool("0"))
|
||||
self.assertFalse(parse_bool("false"))
|
||||
|
||||
def test_close_still_closes_supervisor_when_queued_job_write_fails(self):
|
||||
queued = {"state": "queued"}
|
||||
with (
|
||||
patch.object(self.manager, "_iter_jobs", return_value=[queued]),
|
||||
patch.object(self.manager, "_write_job", side_effect=OSError("disk full")),
|
||||
patch.object(self.manager._supervisor, "close") as supervisor_close,
|
||||
):
|
||||
with self.assertRaises(OSError):
|
||||
self.manager.close()
|
||||
|
||||
supervisor_close.assert_called_once_with()
|
||||
|
||||
def test_uploads_are_staged_in_runtime_not_vault(self):
|
||||
upload = self.manager.create_upload(file_name="drawing.pdf", size_bytes=0, sha256=self._sha256(b""))
|
||||
upload_path = self._upload_payload_path(upload["upload_id"]).resolve()
|
||||
@@ -193,6 +207,56 @@ class McpIngestTests(unittest.TestCase):
|
||||
self.assertNotIn("path", upload)
|
||||
self.assertEqual(self._upload_payload_path(upload["upload_id"]).read_bytes(), payload)
|
||||
|
||||
def test_create_completed_upload_from_stream_reads_bounded_chunks(self):
|
||||
payload = b"streamed upload payload" * 100_000
|
||||
|
||||
class BoundedReadStream(io.BytesIO):
|
||||
def __init__(self, data):
|
||||
super().__init__(data)
|
||||
self.read_sizes = []
|
||||
|
||||
def read(self, size=-1):
|
||||
if size < 0:
|
||||
raise AssertionError("stream upload must not use an unbounded read")
|
||||
self.read_sizes.append(size)
|
||||
return super().read(size)
|
||||
|
||||
manager = IngestManager(
|
||||
vault_path=self.vault,
|
||||
project_root=self.project,
|
||||
runtime_root=self.root / "stream-runtime",
|
||||
max_file_bytes=len(payload) + 1,
|
||||
)
|
||||
stream = BoundedReadStream(payload)
|
||||
|
||||
upload = manager.create_completed_upload_from_stream(
|
||||
file_name="stream.bin",
|
||||
stream=stream,
|
||||
size_bytes=len(payload),
|
||||
)
|
||||
|
||||
self.assertEqual(upload["state"], "complete")
|
||||
self.assertEqual(upload["sha256"], "sha256:" + self._sha256(payload))
|
||||
self.assertGreater(len(stream.read_sizes), 2)
|
||||
self.assertTrue(all(size == 1024 * 1024 for size in stream.read_sizes))
|
||||
|
||||
def test_create_completed_upload_from_stream_cleans_partial_file_when_oversized(self):
|
||||
manager = IngestManager(
|
||||
vault_path=self.vault,
|
||||
project_root=self.project,
|
||||
runtime_root=self.root / "small-stream-runtime",
|
||||
max_file_bytes=5,
|
||||
)
|
||||
|
||||
with self.assertRaises(IngestError):
|
||||
manager.create_completed_upload_from_stream(
|
||||
file_name="too-large.bin",
|
||||
stream=io.BytesIO(b"123456"),
|
||||
)
|
||||
|
||||
self.assertEqual(list(manager.upload_root.rglob("upload.json")), [])
|
||||
self.assertEqual([path for path in manager.upload_root.rglob("*") if path.is_file()], [])
|
||||
|
||||
def test_upload_ticket_is_single_use_and_preserves_defaults(self):
|
||||
ticket = self.manager.create_upload_ticket(
|
||||
expires_seconds=60,
|
||||
@@ -341,6 +405,8 @@ class McpIngestTests(unittest.TestCase):
|
||||
self.assertNotIn("../secret", prompt)
|
||||
self.assertNotIn("path", job["files"][0])
|
||||
|
||||
log_path = Path(self.manager._load_job(job["job_id"])["log_path"])
|
||||
log_path.write_text(self._json_text_event("MCP_INGEST_OK\nIngested source.pdf"), encoding="utf-8")
|
||||
popen.returncode = 0
|
||||
self.manager.refresh_jobs()
|
||||
status = self.manager.get_job_status(job["job_id"])
|
||||
@@ -348,6 +414,61 @@ class McpIngestTests(unittest.TestCase):
|
||||
self.assertEqual(status["exit_code"], 0)
|
||||
self.assertNotIn("path", status["files"][0])
|
||||
|
||||
def test_ingest_result_rejects_skill_failure_with_zero_exit_code(self):
|
||||
upload = self._complete_upload("source.pdf", b"source data")
|
||||
popen = FakePopen(returncode=None)
|
||||
|
||||
with (
|
||||
patch("obsidian_wiki.mcp_ingest.subprocess.run", side_effect=self._fake_static_readiness_run),
|
||||
patch("obsidian_wiki.mcp_ingest.subprocess.Popen", return_value=popen),
|
||||
):
|
||||
job = self.manager.start_ingest_job([upload["upload_id"]])
|
||||
|
||||
log_path = Path(self.manager._load_job(job["job_id"])["log_path"])
|
||||
log_path.write_text(self._json_text_event("MCP_INGEST_FAILED\nSKILL_LOAD_FAILED: missing"), encoding="utf-8")
|
||||
popen.returncode = 0
|
||||
result = self.manager.get_ingest_job_result(job["job_id"])
|
||||
|
||||
self.assertEqual(result["state"], "failed")
|
||||
self.assertIn("SKILL_LOAD_FAILED", result["error"])
|
||||
|
||||
def test_ingest_public_status_hides_raw_tool_logs_paths_and_pid(self):
|
||||
upload = self._complete_upload("source.pdf", b"source data")
|
||||
popen = FakePopen(returncode=None)
|
||||
|
||||
with (
|
||||
patch("obsidian_wiki.mcp_ingest.subprocess.run", side_effect=self._fake_static_readiness_run),
|
||||
patch("obsidian_wiki.mcp_ingest.subprocess.Popen", return_value=popen),
|
||||
):
|
||||
job = self.manager.start_ingest_job([upload["upload_id"]])
|
||||
|
||||
log_path = Path(self.manager._load_job(job["job_id"])["log_path"])
|
||||
log_path.write_text(
|
||||
'{"type":"tool_use","part":{"state":{"output":"/home/private/secret"}}}\n'
|
||||
+ self._json_text_event("MCP_INGEST_OK\nIngest complete"),
|
||||
encoding="utf-8",
|
||||
)
|
||||
popen.returncode = 0
|
||||
result = self.manager.get_ingest_job_result(job["job_id"])
|
||||
status = self.manager.get_ingest_job_status(job["job_id"])
|
||||
|
||||
self.assertEqual(result["state"], "succeeded")
|
||||
self.assertEqual(result["opencode_output_tail"], "Ingest complete")
|
||||
self.assertNotIn("/home/private", result["opencode_output_tail"])
|
||||
self.assertNotIn("pid", status)
|
||||
self.assertNotIn("checked_paths", status["preflight"])
|
||||
|
||||
internal = self.manager._load_job(job["job_id"])
|
||||
internal["error"] = f"failed while reading {self.project / 'secret.txt'}"
|
||||
internal["preflight"]["warnings"] = [f"check {self.runtime / 'private.log'}"]
|
||||
self.manager._write_job(internal)
|
||||
redacted = self.manager.get_ingest_job_status(job["job_id"])
|
||||
serialized = json.dumps(redacted)
|
||||
self.assertNotIn(str(self.project), serialized)
|
||||
self.assertNotIn(str(self.runtime), serialized)
|
||||
self.assertIn("<project>", serialized)
|
||||
self.assertIn("<runtime>", serialized)
|
||||
|
||||
def test_jobs_queue_one_at_a_time_and_cancel_queued_job(self):
|
||||
first = self._complete_upload("first.pdf", b"first")
|
||||
second = self._complete_upload("second.pdf", b"second")
|
||||
@@ -367,6 +488,27 @@ class McpIngestTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual(cancelled["state"], "cancelled")
|
||||
|
||||
def test_start_ingest_job_is_idempotent_for_claimed_uploads(self):
|
||||
upload = self._complete_upload("source.pdf", b"source")
|
||||
popen = FakePopen(returncode=None)
|
||||
|
||||
with (
|
||||
patch("obsidian_wiki.mcp_ingest.subprocess.run", side_effect=self._fake_static_readiness_run),
|
||||
patch("obsidian_wiki.mcp_ingest.subprocess.Popen", return_value=popen) as popen_mock,
|
||||
):
|
||||
first = self.manager.start_ingest_job([upload["upload_id"]])
|
||||
repeated = self.manager.start_ingest_job([upload["upload_id"]])
|
||||
|
||||
self.assertEqual(repeated["job_id"], first["job_id"])
|
||||
self.assertEqual(repeated["state"], "running")
|
||||
popen_mock.assert_called_once()
|
||||
|
||||
def test_start_ingest_job_rejects_duplicate_upload_ids(self):
|
||||
upload = self._complete_upload("source.pdf", b"source")
|
||||
|
||||
with self.assertRaisesRegex(IngestError, "upload_ids must be unique"):
|
||||
self.manager.start_ingest_job([upload["upload_id"], upload["upload_id"]])
|
||||
|
||||
def test_cleanup_runtime_prunes_expired_tickets_and_old_completed_artifacts(self):
|
||||
upload = self._complete_upload("old.pdf", b"old payload")
|
||||
ticket = self.manager.create_upload_ticket(expires_seconds=60, max_files=1)
|
||||
@@ -476,6 +618,18 @@ class McpIngestTests(unittest.TestCase):
|
||||
raise subprocess.TimeoutExpired(args, kwargs.get("timeout"))
|
||||
raise AssertionError(f"unexpected subprocess.run args: {args}")
|
||||
|
||||
def _json_text_event(self, text):
|
||||
return json.dumps(
|
||||
{
|
||||
"type": "text",
|
||||
"part": {
|
||||
"type": "text",
|
||||
"text": text,
|
||||
"metadata": {"openai": {"phase": "final_answer"}},
|
||||
},
|
||||
}
|
||||
) + "\n"
|
||||
|
||||
|
||||
class FakePopen:
|
||||
def __init__(self, returncode=None):
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import json
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from obsidian_wiki.mcp_jobs import (
|
||||
ProcessSupervisor,
|
||||
RuntimeLeaseError,
|
||||
extract_final_answer,
|
||||
parse_marked_result,
|
||||
)
|
||||
|
||||
|
||||
class ProcessSupervisorTests(unittest.TestCase):
|
||||
def test_long_final_answer_keeps_protocol_marker_when_truncated(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
log_path = Path(tmp) / "opencode.log"
|
||||
answer = "MCP_QUERY_OK\n" + ("x" * 20_000)
|
||||
log_path.write_text(
|
||||
'{"type":"text","part":{"text":' + json.dumps(answer) + "}}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
extracted = extract_final_answer(log_path, limit=12_000)
|
||||
ok, public_text, error = parse_marked_result(
|
||||
extracted,
|
||||
success_marker="MCP_QUERY_OK",
|
||||
failure_marker="MCP_QUERY_FAILED",
|
||||
)
|
||||
|
||||
self.assertTrue(ok, error)
|
||||
self.assertTrue(extracted.startswith("MCP_QUERY_OK\n"))
|
||||
self.assertLessEqual(len(extracted), 12_000)
|
||||
self.assertEqual(len(public_text), 12_000 - len("MCP_QUERY_OK\n"))
|
||||
def test_completion_callback_runs_without_client_polling(self):
|
||||
process = FakeProcess(returncode=0)
|
||||
completed = threading.Event()
|
||||
outcomes = []
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
supervisor = ProcessSupervisor(
|
||||
Path(tmp),
|
||||
popen_factory=lambda *_args, **_kwargs: process,
|
||||
poll_interval_seconds=0.01,
|
||||
)
|
||||
supervisor.start(
|
||||
"job-1",
|
||||
["opencode", "run"],
|
||||
cwd=Path(tmp),
|
||||
log_path=Path(tmp) / "job.log",
|
||||
timeout_seconds=30,
|
||||
on_complete=lambda outcome: (outcomes.append(outcome), completed.set()),
|
||||
)
|
||||
|
||||
self.assertTrue(completed.wait(1))
|
||||
self.assertEqual(outcomes[0].returncode, 0)
|
||||
self.assertFalse(outcomes[0].cancelled)
|
||||
self.assertFalse(outcomes[0].timed_out)
|
||||
supervisor.close()
|
||||
|
||||
def test_completion_callback_failure_invokes_error_fallback(self):
|
||||
process = FakeProcess(returncode=0)
|
||||
recovered = threading.Event()
|
||||
failures = []
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
supervisor = ProcessSupervisor(
|
||||
Path(tmp),
|
||||
popen_factory=lambda *_args, **_kwargs: process,
|
||||
poll_interval_seconds=0.01,
|
||||
)
|
||||
|
||||
def fail_callback(_outcome):
|
||||
raise RuntimeError("callback failed")
|
||||
|
||||
supervisor.start(
|
||||
"job-1",
|
||||
["opencode", "run"],
|
||||
cwd=Path(tmp),
|
||||
log_path=Path(tmp) / "job.log",
|
||||
timeout_seconds=30,
|
||||
on_complete=fail_callback,
|
||||
on_callback_error=lambda outcome, error: (
|
||||
failures.append((outcome, error)),
|
||||
recovered.set(),
|
||||
),
|
||||
)
|
||||
|
||||
self.assertTrue(recovered.wait(1))
|
||||
self.assertEqual(failures[0][0].job_id, "job-1")
|
||||
self.assertEqual(str(failures[0][1]), "callback failed")
|
||||
supervisor.close()
|
||||
|
||||
def test_cancel_waits_for_exit_before_reporting_completion(self):
|
||||
process = FakeProcess(returncode=None, terminate_returncode=-15)
|
||||
outcomes = []
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
supervisor = ProcessSupervisor(
|
||||
Path(tmp),
|
||||
popen_factory=lambda *_args, **_kwargs: process,
|
||||
poll_interval_seconds=0.01,
|
||||
)
|
||||
supervisor.start(
|
||||
"job-1",
|
||||
["opencode", "run"],
|
||||
cwd=Path(tmp),
|
||||
log_path=Path(tmp) / "job.log",
|
||||
timeout_seconds=30,
|
||||
on_complete=outcomes.append,
|
||||
)
|
||||
|
||||
self.assertTrue(supervisor.cancel("job-1", grace_seconds=0.1))
|
||||
self.assertTrue(process.terminated)
|
||||
self.assertEqual(process.wait_calls, 1)
|
||||
self.assertEqual(len(outcomes), 1)
|
||||
self.assertTrue(outcomes[0].cancelled)
|
||||
self.assertEqual(outcomes[0].returncode, -15)
|
||||
supervisor.close()
|
||||
|
||||
def test_timeout_terminates_process_and_marks_outcome(self):
|
||||
process = FakeProcess(returncode=None, terminate_returncode=-15)
|
||||
completed = threading.Event()
|
||||
outcomes = []
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
supervisor = ProcessSupervisor(
|
||||
Path(tmp),
|
||||
popen_factory=lambda *_args, **_kwargs: process,
|
||||
poll_interval_seconds=0.01,
|
||||
)
|
||||
supervisor.start(
|
||||
"job-1",
|
||||
["opencode", "run"],
|
||||
cwd=Path(tmp),
|
||||
log_path=Path(tmp) / "job.log",
|
||||
timeout_seconds=0.01,
|
||||
on_complete=lambda outcome: (outcomes.append(outcome), completed.set()),
|
||||
)
|
||||
|
||||
self.assertTrue(completed.wait(1))
|
||||
self.assertTrue(process.terminated)
|
||||
self.assertTrue(outcomes[0].timed_out)
|
||||
supervisor.close()
|
||||
|
||||
def test_runtime_root_allows_only_one_supervisor(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
first = ProcessSupervisor(Path(tmp), poll_interval_seconds=0.01)
|
||||
with self.assertRaises(RuntimeLeaseError):
|
||||
ProcessSupervisor(Path(tmp), poll_interval_seconds=0.01)
|
||||
first.close()
|
||||
|
||||
def test_closed_supervisor_rejects_start_without_spawning(self):
|
||||
calls = []
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
supervisor = ProcessSupervisor(
|
||||
Path(tmp),
|
||||
popen_factory=lambda *_args, **_kwargs: calls.append(True),
|
||||
poll_interval_seconds=0.01,
|
||||
)
|
||||
supervisor.close()
|
||||
|
||||
with self.assertRaises(RuntimeError):
|
||||
supervisor.start(
|
||||
"job-1",
|
||||
["opencode", "run"],
|
||||
cwd=Path(tmp),
|
||||
log_path=Path(tmp) / "job.log",
|
||||
timeout_seconds=30,
|
||||
on_complete=lambda _outcome: None,
|
||||
)
|
||||
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
|
||||
class FakeProcess:
|
||||
def __init__(self, *, returncode, terminate_returncode=-15):
|
||||
self.returncode = returncode
|
||||
self.terminate_returncode = terminate_returncode
|
||||
self.pid = 4242
|
||||
self.terminated = False
|
||||
self.killed = False
|
||||
self.wait_calls = 0
|
||||
|
||||
def poll(self):
|
||||
return self.returncode
|
||||
|
||||
def wait(self, timeout=None):
|
||||
self.wait_calls += 1
|
||||
if self.returncode is None:
|
||||
raise subprocess.TimeoutExpired("fake", timeout)
|
||||
return self.returncode
|
||||
|
||||
def terminate(self):
|
||||
self.terminated = True
|
||||
self.returncode = self.terminate_returncode
|
||||
|
||||
def kill(self):
|
||||
self.killed = True
|
||||
self.returncode = -9
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+171
-8
@@ -1,4 +1,5 @@
|
||||
import subprocess
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
@@ -28,6 +29,7 @@ class McpQueryTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
self.manager.close()
|
||||
self.tempdir.cleanup()
|
||||
|
||||
def test_check_opencode_readiness_static_success(self):
|
||||
@@ -71,7 +73,7 @@ class McpQueryTests(unittest.TestCase):
|
||||
self.assertEqual(result["runtime_smoke_test"], "passed")
|
||||
self.assertEqual(run_mock.call_count, 3)
|
||||
smoke_args = run_mock.call_args_list[-1].args[0]
|
||||
self.assertEqual(smoke_args[:4], ["/usr/bin/opencode", "run", "--format", "default"])
|
||||
self.assertEqual(smoke_args[:4], ["/usr/bin/opencode", "run", "--format", "json"])
|
||||
self.assertEqual(smoke_args[smoke_args.index("--model") + 1], "openai/gpt-5.5")
|
||||
self.assertEqual(smoke_args[smoke_args.index("--variant") + 1], "minimal")
|
||||
self.assertIn("--auto", smoke_args)
|
||||
@@ -86,6 +88,18 @@ class McpQueryTests(unittest.TestCase):
|
||||
with self.assertRaises(QueryError):
|
||||
self.manager.start_query_job("what do I know?", mode="full")
|
||||
|
||||
def test_close_still_closes_supervisor_when_queued_job_write_fails(self):
|
||||
queued = {"state": "queued"}
|
||||
with (
|
||||
patch.object(self.manager, "_iter_jobs", return_value=[queued]),
|
||||
patch.object(self.manager, "_write_job", side_effect=OSError("disk full")),
|
||||
patch.object(self.manager._supervisor, "close") as supervisor_close,
|
||||
):
|
||||
with self.assertRaises(OSError):
|
||||
self.manager.close()
|
||||
|
||||
supervisor_close.assert_called_once_with()
|
||||
|
||||
def test_start_query_job_constructs_restricted_opencode_command(self):
|
||||
self.manager.opencode_model = "openai/gpt-5.5"
|
||||
self.manager.opencode_variant = "minimal"
|
||||
@@ -103,7 +117,7 @@ class McpQueryTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
args = popen_mock.call_args.args[0]
|
||||
self.assertEqual(args[:5], ["/usr/bin/opencode", "run", "--format", "default", "--auto"])
|
||||
self.assertEqual(args[:5], ["/usr/bin/opencode", "run", "--format", "json", "--auto"])
|
||||
self.assertEqual(args[args.index("--model") + 1], "openai/gpt-5.5")
|
||||
self.assertEqual(args[args.index("--variant") + 1], "minimal")
|
||||
self.assertIn("--dir", args)
|
||||
@@ -144,7 +158,10 @@ class McpQueryTests(unittest.TestCase):
|
||||
status = self.manager.get_query_job_status(job["job_id"])
|
||||
self.assertNotIn("log_path", status)
|
||||
log_path = Path(self.manager._load_job(job["job_id"])["log_path"])
|
||||
log_path.write_text("Based on the wiki:\nAnswer body\n", encoding="utf-8")
|
||||
log_path.write_text(
|
||||
self._json_text_event("MCP_QUERY_OK\nBased on the wiki:\nAnswer body"),
|
||||
encoding="utf-8",
|
||||
)
|
||||
popen.returncode = 0
|
||||
result = self.manager.get_query_job_result(job["job_id"])
|
||||
|
||||
@@ -170,9 +187,12 @@ class McpQueryTests(unittest.TestCase):
|
||||
|
||||
log_path = Path(self.manager._load_job(job["job_id"])["log_path"])
|
||||
log_path.write_text(
|
||||
"Based on [[Mini FAKRA]] and [[BJD]].\n"
|
||||
"Business facts used. Data through 2026-06-02.\n"
|
||||
"Answer body\n",
|
||||
self._json_text_event(
|
||||
"MCP_QUERY_OK\n"
|
||||
"Based on [[Mini FAKRA]] and [[BJD]].\n"
|
||||
"Business facts used. Data through 2026-06-02.\n"
|
||||
"Answer body"
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
popen.returncode = 0
|
||||
@@ -195,13 +215,67 @@ class McpQueryTests(unittest.TestCase):
|
||||
job = self.manager.start_query_job("what failed?")
|
||||
|
||||
log_path = Path(self.manager._load_job(job["job_id"])["log_path"])
|
||||
log_path.write_text("Traceback: failed\n", encoding="utf-8")
|
||||
log_path.write_text(self._json_text_event("MCP_QUERY_FAILED\nTraceback: failed"), encoding="utf-8")
|
||||
result = self.manager.get_query_job_result(job["job_id"])
|
||||
|
||||
self.assertEqual(result["state"], "failed")
|
||||
self.assertEqual(result["confidence"], "low")
|
||||
self.assertTrue(any("failed" in warning.lower() for warning in result["warnings"]))
|
||||
|
||||
def test_query_result_rejects_skill_failure_with_zero_exit_code(self):
|
||||
popen = FakePopen(returncode=None)
|
||||
|
||||
with (
|
||||
patch("obsidian_wiki.mcp_query.subprocess.run", side_effect=self._fake_static_readiness_run),
|
||||
patch("obsidian_wiki.mcp_query.subprocess.Popen", return_value=popen),
|
||||
):
|
||||
job = self.manager.start_query_job("what failed?")
|
||||
|
||||
log_path = Path(self.manager._load_job(job["job_id"])["log_path"])
|
||||
log_path.write_text(self._json_text_event("MCP_QUERY_FAILED\nSKILL_LOAD_FAILED: missing"), encoding="utf-8")
|
||||
popen.returncode = 0
|
||||
result = self.manager.get_query_job_result(job["job_id"])
|
||||
|
||||
self.assertEqual(result["state"], "failed")
|
||||
self.assertEqual(result["confidence"], "low")
|
||||
self.assertIn("SKILL_LOAD_FAILED", result["error"])
|
||||
|
||||
def test_query_result_accepts_only_final_answer_event_and_hides_paths(self):
|
||||
popen = FakePopen(returncode=None)
|
||||
|
||||
with (
|
||||
patch("obsidian_wiki.mcp_query.subprocess.run", side_effect=self._fake_static_readiness_run),
|
||||
patch("obsidian_wiki.mcp_query.subprocess.Popen", return_value=popen),
|
||||
):
|
||||
job = self.manager.start_query_job("safe answer?")
|
||||
|
||||
log_path = Path(self.manager._load_job(job["job_id"])["log_path"])
|
||||
log_path.write_text(
|
||||
'{"type":"tool_use","part":{"state":{"output":"/home/private/secret"}}}\n'
|
||||
+ self._json_text_event("MCP_QUERY_OK\nBased on [[BJD]]."),
|
||||
encoding="utf-8",
|
||||
)
|
||||
popen.returncode = 0
|
||||
result = self.manager.get_query_job_result(job["job_id"])
|
||||
|
||||
self.assertEqual(result["state"], "succeeded")
|
||||
self.assertEqual(result["answer_markdown"], "Based on [[BJD]].")
|
||||
self.assertNotIn("/home/private", result["opencode_output_tail"])
|
||||
status = self.manager.get_query_job_status(job["job_id"])
|
||||
self.assertNotIn("pid", status)
|
||||
self.assertNotIn("checked_paths", status["preflight"])
|
||||
|
||||
internal = self.manager._load_job(job["job_id"])
|
||||
internal["error"] = f"failed while reading {self.project / 'secret.txt'}"
|
||||
internal["preflight"]["warnings"] = [f"check {self.runtime / 'private.log'}"]
|
||||
self.manager._write_job(internal)
|
||||
redacted = self.manager.get_query_job_status(job["job_id"])
|
||||
serialized = json.dumps(redacted)
|
||||
self.assertNotIn(str(self.project), serialized)
|
||||
self.assertNotIn(str(self.runtime), serialized)
|
||||
self.assertIn("<project>", serialized)
|
||||
self.assertIn("<runtime>", serialized)
|
||||
|
||||
def test_ask_wiki_returns_timeout_payload_with_job_id(self):
|
||||
popen = FakePopen(returncode=None)
|
||||
|
||||
@@ -237,6 +311,76 @@ class McpQueryTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual(cancelled["state"], "cancelled")
|
||||
|
||||
def test_close_cancels_running_and_queued_jobs_and_releases_runtime(self):
|
||||
popen = FakePopen(returncode=None)
|
||||
with (
|
||||
patch("obsidian_wiki.mcp_query.subprocess.run", side_effect=self._fake_static_readiness_run),
|
||||
patch("obsidian_wiki.mcp_query.subprocess.Popen", return_value=popen),
|
||||
):
|
||||
first = self.manager.start_query_job("first query")
|
||||
second = self.manager.start_query_job("second query")
|
||||
self.manager.close()
|
||||
|
||||
self.assertEqual(self.manager._load_job(first["job_id"])["state"], "cancelled")
|
||||
self.assertEqual(self.manager._load_job(second["job_id"])["state"], "cancelled")
|
||||
replacement = QueryManager(
|
||||
vault_path=self.vault,
|
||||
project_root=self.project,
|
||||
runtime_root=self.runtime,
|
||||
opencode_path=Path("/usr/bin/opencode"),
|
||||
)
|
||||
replacement.close()
|
||||
|
||||
with self.assertRaises(QueryError):
|
||||
self.manager.start_query_job("after close")
|
||||
|
||||
def test_completed_job_advances_queue_without_status_polling(self):
|
||||
first_process = FakePopen(returncode=None)
|
||||
second_process = FakePopen(returncode=None)
|
||||
|
||||
with (
|
||||
patch("obsidian_wiki.mcp_query.subprocess.run", side_effect=self._fake_static_readiness_run),
|
||||
patch("obsidian_wiki.mcp_query.subprocess.Popen", side_effect=[first_process, second_process]),
|
||||
):
|
||||
first = self.manager.start_query_job("first query")
|
||||
second = self.manager.start_query_job("second query")
|
||||
first_log = Path(self.manager._load_job(first["job_id"])["log_path"])
|
||||
first_log.write_text(self._json_text_event("MCP_QUERY_OK\nfirst answer"), encoding="utf-8")
|
||||
first_process.returncode = 0
|
||||
|
||||
deadline = time.monotonic() + 1
|
||||
while time.monotonic() < deadline and self.manager._load_job(second["job_id"])["state"] != "running":
|
||||
time.sleep(0.01)
|
||||
|
||||
self.assertEqual(self.manager._load_job(first["job_id"])["state"], "succeeded")
|
||||
self.assertEqual(self.manager._load_job(second["job_id"])["state"], "running")
|
||||
|
||||
def test_failed_preflight_does_not_block_later_queued_job(self):
|
||||
first_process = FakePopen(returncode=None)
|
||||
third_process = FakePopen(returncode=None)
|
||||
failed_readiness = {"ok": False, "errors": ["skill missing"]}
|
||||
successful_readiness = {"ok": True, "errors": []}
|
||||
|
||||
with (
|
||||
patch("obsidian_wiki.mcp_query.subprocess.run", side_effect=self._fake_static_readiness_run),
|
||||
patch("obsidian_wiki.mcp_query.subprocess.Popen", side_effect=[first_process, third_process]),
|
||||
):
|
||||
first = self.manager.start_query_job("first query")
|
||||
second = self.manager.start_query_job("second query")
|
||||
third = self.manager.start_query_job("third query")
|
||||
first_log = Path(self.manager._load_job(first["job_id"])["log_path"])
|
||||
first_log.write_text(self._json_text_event("MCP_QUERY_OK\nfirst answer"), encoding="utf-8")
|
||||
with patch.object(
|
||||
self.manager,
|
||||
"check_opencode_readiness",
|
||||
side_effect=[failed_readiness, successful_readiness],
|
||||
):
|
||||
completed = self.manager._supervisor._complete_naturally(first["job_id"], 0)
|
||||
|
||||
self.assertTrue(completed)
|
||||
self.assertEqual(self.manager._load_job(second["job_id"])["state"], "failed")
|
||||
self.assertEqual(self.manager._load_job(third["job_id"])["state"], "running")
|
||||
|
||||
def test_cleanup_runtime_prunes_old_terminal_query_jobs(self):
|
||||
popen = FakePopen(returncode=0)
|
||||
with (
|
||||
@@ -290,9 +434,28 @@ class McpQueryTests(unittest.TestCase):
|
||||
if args in (["/usr/bin/opencode", "--version"], ["/usr/bin/opencode", "run", "--help"]):
|
||||
return self._fake_static_readiness_run(args, **kwargs)
|
||||
if args[:3] == ["/usr/bin/opencode", "run", "--format"]:
|
||||
return subprocess.CompletedProcess(args, 0, stdout="WIKI_QUERY_SKILL_LOADED\n", stderr="")
|
||||
return subprocess.CompletedProcess(
|
||||
args,
|
||||
0,
|
||||
stdout=self._json_text_event("WIKI_QUERY_SKILL_LOADED"),
|
||||
stderr="",
|
||||
)
|
||||
raise AssertionError(f"unexpected subprocess.run args: {args}")
|
||||
|
||||
def _json_text_event(self, text):
|
||||
import json
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
"type": "text",
|
||||
"part": {
|
||||
"type": "text",
|
||||
"text": text,
|
||||
"metadata": {"openai": {"phase": "final_answer"}},
|
||||
},
|
||||
}
|
||||
) + "\n"
|
||||
|
||||
|
||||
class FakePopen:
|
||||
def __init__(self, returncode=None):
|
||||
|
||||
@@ -3,6 +3,7 @@ import hashlib
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
@@ -129,6 +130,14 @@ class RemoteMcpServerTests(unittest.TestCase):
|
||||
with patch.dict(os.environ, {"OBSIDIAN_MCP_PORT": "not-an-int"}, clear=True):
|
||||
self.assertEqual(mcp_server.runtime_config_int("OBSIDIAN_MCP_PORT", 8765), 8765)
|
||||
|
||||
with patch.dict(os.environ, {"OBSIDIAN_MCP_ALLOWED_HOSTS": "wiki.example:*, 10.0.0.2:*"}, clear=True):
|
||||
self.assertEqual(
|
||||
mcp_server.runtime_config_list("OBSIDIAN_MCP_ALLOWED_HOSTS"),
|
||||
["wiki.example:*", "10.0.0.2:*"],
|
||||
)
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
self.assertIsNone(mcp_server.runtime_config_list("OBSIDIAN_MCP_ALLOWED_HOSTS"))
|
||||
|
||||
def test_enabled_tools_config_prefers_tool_list_over_legacy_ingest_flag(self):
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
@@ -200,6 +209,130 @@ class RemoteMcpServerTests(unittest.TestCase):
|
||||
self.assertEqual(data["uploads"][0]["sha256"], "sha256:" + hashlib.sha256(payload).hexdigest())
|
||||
self.assertNotIn("path", data["uploads"][0])
|
||||
|
||||
def test_http_upload_endpoint_streams_multipart_without_buffering_whole_body(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
manager = IngestManager(
|
||||
vault_path=root / "vault",
|
||||
project_root=root / "project",
|
||||
runtime_root=root / "runtime",
|
||||
)
|
||||
app = mcp_server.create_http_app(FakeApp(), "secret-token", ingest_manager=manager)
|
||||
payload = b"stream this upload"
|
||||
body, boundary = multipart_body(
|
||||
{"start_job": "false"},
|
||||
[("files", "stream.txt", "text/plain", payload)],
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"obsidian_wiki.mcp_server._read_http_body",
|
||||
side_effect=AssertionError("buffered body"),
|
||||
create=True,
|
||||
),
|
||||
patch.object(manager, "create_completed_upload", side_effect=AssertionError("bytes upload")),
|
||||
):
|
||||
messages = run_asgi_http_request(
|
||||
app,
|
||||
method="POST",
|
||||
path="/mcp-ingest/uploads",
|
||||
headers=[
|
||||
(b"authorization", b"Bearer secret-token"),
|
||||
(b"content-type", f"multipart/form-data; boundary={boundary}".encode("ascii")),
|
||||
],
|
||||
body=body,
|
||||
)
|
||||
|
||||
self.assertEqual(messages[0]["status"], 200)
|
||||
response = b"".join(message.get("body", b"") for message in messages if message["type"] == "http.response.body")
|
||||
data = json.loads(response.decode("utf-8"))
|
||||
self.assertEqual(data["uploads"][0]["sha256"], "sha256:" + hashlib.sha256(payload).hexdigest())
|
||||
|
||||
def test_http_upload_file_copy_does_not_block_event_loop(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
manager = IngestManager(
|
||||
vault_path=root / "vault",
|
||||
project_root=root / "project",
|
||||
runtime_root=root / "runtime",
|
||||
)
|
||||
app = mcp_server.create_http_app(FakeApp(), "secret-token", ingest_manager=manager)
|
||||
body, boundary = multipart_body(
|
||||
{"start_job": "false"},
|
||||
[("files", "stream.txt", "text/plain", b"stream this upload")],
|
||||
)
|
||||
original = manager.create_completed_upload_from_stream
|
||||
|
||||
def slow_copy(*args, **kwargs):
|
||||
time.sleep(0.1)
|
||||
return original(*args, **kwargs)
|
||||
|
||||
async def exercise():
|
||||
messages = []
|
||||
sent = False
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"path": "/mcp-ingest/uploads",
|
||||
"raw_path": b"/mcp-ingest/uploads",
|
||||
"query_string": b"",
|
||||
"headers": [
|
||||
(b"authorization", b"Bearer secret-token"),
|
||||
(b"content-type", f"multipart/form-data; boundary={boundary}".encode("ascii")),
|
||||
],
|
||||
}
|
||||
|
||||
async def receive():
|
||||
nonlocal sent
|
||||
if sent:
|
||||
return {"type": "http.disconnect"}
|
||||
sent = True
|
||||
return {"type": "http.request", "body": body, "more_body": False}
|
||||
|
||||
async def send(message):
|
||||
messages.append(message)
|
||||
|
||||
started = time.monotonic()
|
||||
request = asyncio.create_task(app(scope, receive, send))
|
||||
await asyncio.sleep(0.01)
|
||||
responsive_after = time.monotonic() - started
|
||||
await request
|
||||
return responsive_after, messages
|
||||
|
||||
with patch.object(manager, "create_completed_upload_from_stream", side_effect=slow_copy):
|
||||
responsive_after, messages = asyncio.run(exercise())
|
||||
|
||||
self.assertLess(responsive_after, 0.08)
|
||||
self.assertEqual(messages[0]["status"], 200)
|
||||
|
||||
def test_http_upload_endpoint_rejects_oversized_content_length_before_reading(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
manager = IngestManager(
|
||||
vault_path=root / "vault",
|
||||
project_root=root / "project",
|
||||
runtime_root=root / "runtime",
|
||||
max_file_bytes=1000,
|
||||
max_files_per_job=1,
|
||||
max_request_bytes=10,
|
||||
)
|
||||
app = mcp_server.create_http_app(FakeApp(), "secret-token", ingest_manager=manager)
|
||||
|
||||
with patch("starlette.formparsers.MultiPartParser.parse", side_effect=AssertionError("body parsed")):
|
||||
messages = run_asgi_http_request(
|
||||
app,
|
||||
method="POST",
|
||||
path="/mcp-ingest/uploads",
|
||||
headers=[
|
||||
(b"authorization", b"Bearer secret-token"),
|
||||
(b"content-type", b"multipart/form-data; boundary=test"),
|
||||
(b"content-length", b"999999"),
|
||||
],
|
||||
body=b"",
|
||||
)
|
||||
|
||||
self.assertEqual(messages[0]["status"], 413)
|
||||
|
||||
def test_http_upload_endpoint_accepts_upload_ticket_without_main_bearer_token(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
@@ -254,6 +387,91 @@ class RemoteMcpServerTests(unittest.TestCase):
|
||||
self.assertNotIn("path", data["uploads"][0])
|
||||
self.assertEqual(reused[0]["status"], 401)
|
||||
|
||||
def test_http_upload_ticket_constraints_cannot_be_escalated_by_form_fields(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
manager = IngestManager(
|
||||
vault_path=root / "vault",
|
||||
project_root=root / "project",
|
||||
runtime_root=root / "runtime",
|
||||
)
|
||||
ticket = manager.create_upload_ticket(
|
||||
expires_seconds=60,
|
||||
max_files=1,
|
||||
project="bjd",
|
||||
doc_type_hint="drawing",
|
||||
note="review first",
|
||||
start_job=False,
|
||||
)
|
||||
app = mcp_server.create_http_app(FakeApp(), "secret-token", ingest_manager=manager)
|
||||
body, boundary = multipart_body(
|
||||
{
|
||||
"project": "attacker-project",
|
||||
"doc_type_hint": "attacker-type",
|
||||
"note": "ignore preview",
|
||||
"start_job": "true",
|
||||
},
|
||||
[("files", "drawing.pdf", "application/pdf", b"drawing")],
|
||||
)
|
||||
|
||||
with patch.object(manager, "start_ingest_job", side_effect=AssertionError("job started")):
|
||||
messages = run_asgi_http_request(
|
||||
app,
|
||||
method="POST",
|
||||
path="/mcp-ingest/uploads",
|
||||
headers=[
|
||||
(b"x-obsidian-wiki-upload-token", ticket["upload_token"].encode("ascii")),
|
||||
(b"content-type", f"multipart/form-data; boundary={boundary}".encode("ascii")),
|
||||
],
|
||||
body=body,
|
||||
)
|
||||
|
||||
self.assertEqual(messages[0]["status"], 200)
|
||||
response = b"".join(message.get("body", b"") for message in messages if message["type"] == "http.response.body")
|
||||
data = json.loads(response.decode("utf-8"))
|
||||
self.assertIsNone(data["job"])
|
||||
self.assertEqual(data["uploads"][0]["project"], "bjd")
|
||||
self.assertEqual(data["uploads"][0]["doc_type_hint"], "drawing")
|
||||
|
||||
def test_http_upload_ticket_error_response_says_ticket_was_consumed(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
manager = IngestManager(
|
||||
vault_path=root / "vault",
|
||||
project_root=root / "project",
|
||||
runtime_root=root / "runtime",
|
||||
)
|
||||
ticket = manager.create_upload_ticket(expires_seconds=60, max_files=1, start_job=False)
|
||||
app = mcp_server.create_http_app(FakeApp(), "secret-token", ingest_manager=manager)
|
||||
|
||||
messages = run_asgi_http_request(
|
||||
app,
|
||||
method="POST",
|
||||
path="/mcp-ingest/uploads",
|
||||
headers=[
|
||||
(b"x-obsidian-wiki-upload-token", ticket["upload_token"].encode("ascii")),
|
||||
(b"content-type", b"text/plain"),
|
||||
],
|
||||
body=b"not multipart",
|
||||
)
|
||||
reused = run_asgi_http_request(
|
||||
app,
|
||||
method="POST",
|
||||
path="/mcp-ingest/uploads",
|
||||
headers=[
|
||||
(b"x-obsidian-wiki-upload-token", ticket["upload_token"].encode("ascii")),
|
||||
(b"content-type", b"text/plain"),
|
||||
],
|
||||
body=b"not multipart",
|
||||
)
|
||||
|
||||
self.assertEqual(messages[0]["status"], 400)
|
||||
response = b"".join(message.get("body", b"") for message in messages if message["type"] == "http.response.body")
|
||||
data = json.loads(response.decode("utf-8"))
|
||||
self.assertEqual(data["retry_action"], "create_new_upload_ticket")
|
||||
self.assertTrue(data["ticket_consumed"])
|
||||
self.assertEqual(reused[0]["status"], 401)
|
||||
|
||||
def test_http_upload_ticket_is_not_consumed_by_non_post_request(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
@@ -287,6 +505,40 @@ class RemoteMcpServerTests(unittest.TestCase):
|
||||
self.assertEqual(get_messages[0]["status"], 405)
|
||||
self.assertEqual(post_messages[0]["status"], 200)
|
||||
|
||||
def test_http_upload_ticket_is_not_accepted_in_query_string(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
manager = IngestManager(
|
||||
vault_path=root / "vault",
|
||||
project_root=root / "project",
|
||||
runtime_root=root / "runtime",
|
||||
)
|
||||
ticket = manager.create_upload_ticket(expires_seconds=60, max_files=1, start_job=False)
|
||||
app = mcp_server.create_http_app(FakeApp(), "secret-token", ingest_manager=manager)
|
||||
body, boundary = multipart_body({}, [("files", "safe.txt", "text/plain", b"safe")])
|
||||
|
||||
query_auth = run_asgi_http_request(
|
||||
app,
|
||||
method="POST",
|
||||
path="/mcp-ingest/uploads",
|
||||
query_string="upload_token=" + ticket["upload_token"],
|
||||
headers=[(b"content-type", f"multipart/form-data; boundary={boundary}".encode("ascii"))],
|
||||
body=body,
|
||||
)
|
||||
header_auth = run_asgi_http_request(
|
||||
app,
|
||||
method="POST",
|
||||
path="/mcp-ingest/uploads",
|
||||
headers=[
|
||||
(b"x-obsidian-wiki-upload-token", ticket["upload_token"].encode("ascii")),
|
||||
(b"content-type", f"multipart/form-data; boundary={boundary}".encode("ascii")),
|
||||
],
|
||||
body=body,
|
||||
)
|
||||
|
||||
self.assertEqual(query_auth[0]["status"], 401)
|
||||
self.assertEqual(header_auth[0]["status"], 200)
|
||||
|
||||
def test_http_upload_endpoint_rejects_oversized_body_with_413(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
@@ -313,6 +565,74 @@ class RemoteMcpServerTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual(messages[0]["status"], 413)
|
||||
|
||||
def test_http_upload_endpoint_rejects_oversized_file_part_during_parsing(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
manager = IngestManager(
|
||||
vault_path=root / "vault",
|
||||
project_root=root / "project",
|
||||
runtime_root=root / "runtime",
|
||||
max_file_bytes=5,
|
||||
max_files_per_job=2,
|
||||
)
|
||||
app = mcp_server.create_http_app(FakeApp(), "secret-token", ingest_manager=manager)
|
||||
body, boundary = multipart_body(
|
||||
{"start_job": "false"},
|
||||
[("files", "too-large.txt", "text/plain", b"123456")],
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
manager,
|
||||
"create_completed_upload_from_stream",
|
||||
side_effect=AssertionError("file was fully parsed"),
|
||||
):
|
||||
messages = run_asgi_http_request(
|
||||
app,
|
||||
method="POST",
|
||||
path="/mcp-ingest/uploads",
|
||||
headers=[
|
||||
(b"authorization", b"Bearer secret-token"),
|
||||
(b"content-type", f"multipart/form-data; boundary={boundary}".encode("ascii")),
|
||||
],
|
||||
body=body,
|
||||
)
|
||||
|
||||
self.assertEqual(messages[0]["status"], 413)
|
||||
|
||||
def test_http_upload_endpoint_rolls_back_files_when_one_file_is_invalid(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
manager = IngestManager(
|
||||
vault_path=root / "vault",
|
||||
project_root=root / "project",
|
||||
runtime_root=root / "runtime",
|
||||
max_file_bytes=5,
|
||||
max_files_per_job=2,
|
||||
)
|
||||
app = mcp_server.create_http_app(FakeApp(), "secret-token", ingest_manager=manager)
|
||||
body, boundary = multipart_body(
|
||||
{"start_job": "false"},
|
||||
[
|
||||
("files", "valid.txt", "text/plain", b"12345"),
|
||||
("files", "../invalid.txt", "text/plain", b"123"),
|
||||
],
|
||||
)
|
||||
|
||||
messages = run_asgi_http_request(
|
||||
app,
|
||||
method="POST",
|
||||
path="/mcp-ingest/uploads",
|
||||
headers=[
|
||||
(b"authorization", b"Bearer secret-token"),
|
||||
(b"content-type", f"multipart/form-data; boundary={boundary}".encode("ascii")),
|
||||
],
|
||||
body=body,
|
||||
)
|
||||
remaining_uploads = list(manager.upload_root.rglob("upload.json"))
|
||||
|
||||
self.assertEqual(messages[0]["status"], 400)
|
||||
self.assertEqual(remaining_uploads, [])
|
||||
|
||||
def test_http_upload_endpoint_enforces_allowed_host_and_origin(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
@@ -325,7 +645,7 @@ class RemoteMcpServerTests(unittest.TestCase):
|
||||
FakeApp(),
|
||||
"secret-token",
|
||||
ingest_manager=manager,
|
||||
allowed_hosts=["good.example"],
|
||||
allowed_hosts=["good.example:*"],
|
||||
allowed_origins=["https://app.example"],
|
||||
)
|
||||
body, boundary = multipart_body(
|
||||
@@ -363,6 +683,35 @@ class RemoteMcpServerTests(unittest.TestCase):
|
||||
self.assertEqual(bad_origin[0]["status"], 403)
|
||||
self.assertEqual(allowed[0]["status"], 200)
|
||||
|
||||
def test_http_upload_endpoint_rejects_present_origin_when_origin_allowlist_is_empty(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
manager = IngestManager(
|
||||
vault_path=root / "vault",
|
||||
project_root=root / "project",
|
||||
runtime_root=root / "runtime",
|
||||
)
|
||||
app = mcp_server.create_http_app(
|
||||
FakeApp(),
|
||||
"secret-token",
|
||||
ingest_manager=manager,
|
||||
allowed_hosts=["good.example"],
|
||||
allowed_origins=[],
|
||||
)
|
||||
|
||||
messages = run_asgi_http_request(
|
||||
app,
|
||||
method="POST",
|
||||
path="/mcp-ingest/uploads",
|
||||
headers=[
|
||||
(b"authorization", b"Bearer secret-token"),
|
||||
(b"host", b"good.example"),
|
||||
(b"origin", b"https://unexpected.example"),
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(messages[0]["status"], 403)
|
||||
|
||||
def test_http_upload_endpoint_requires_ingest_and_bearer_token(self):
|
||||
no_ingest_app = mcp_server.create_http_app(FakeApp(), "secret-token")
|
||||
missing_token = run_asgi_http_request(no_ingest_app, method="POST", path="/mcp-ingest/uploads")
|
||||
@@ -598,6 +947,7 @@ class RemoteMcpServerTests(unittest.TestCase):
|
||||
ingest_runtime_root=root / "ingest-runtime",
|
||||
query_runtime_root=root / "query-runtime",
|
||||
public_base_url="https://wiki.example.test/mcp",
|
||||
allow_source_files=True,
|
||||
ingest_manager=IngestManager(
|
||||
vault_path=root / "vault",
|
||||
project_root=root / "project",
|
||||
@@ -620,12 +970,14 @@ class RemoteMcpServerTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual(capabilities["server_name"], "wiki-file-assets")
|
||||
self.assertIn("server_version", capabilities)
|
||||
self.assertEqual(capabilities["capabilities_schema_version"], 1)
|
||||
self.assertEqual(capabilities["tools_version"], 1)
|
||||
self.assertEqual(capabilities["schema_version"], 2)
|
||||
self.assertEqual(capabilities["capabilities_schema_version"], 2)
|
||||
self.assertEqual(capabilities["tools_version"], 2)
|
||||
self.assertEqual(capabilities["enabled_tools"], ["file-assets", "ingest", "query"])
|
||||
self.assertIn("file_index", capabilities)
|
||||
self.assertIn("record_count", capabilities["file_index"])
|
||||
self.assertIn("last_rebuild_at", capabilities["file_index"])
|
||||
self.assertIn("last_index_rebuild_at", capabilities["file_index"])
|
||||
self.assertTrue(capabilities["query"]["enabled"])
|
||||
self.assertEqual(capabilities["query"]["preferred_tool"], "ask_wiki")
|
||||
self.assertIn("ask_wiki", capabilities["query"]["tools"])
|
||||
@@ -642,11 +994,204 @@ class RemoteMcpServerTests(unittest.TestCase):
|
||||
self.assertIn("last_ingest_at", capabilities["ingest"])
|
||||
self.assertTrue(capabilities["ingest"]["known_limitations"])
|
||||
self.assertIn("search_file_assets", capabilities["file_assets"]["tools"])
|
||||
self.assertTrue(capabilities["file_assets"]["source_file_download_enabled"])
|
||||
self.assertTrue(capabilities["safety"]["arbitrary_shell"])
|
||||
self.assertFalse(capabilities["safety"]["shell_sandbox_enforced"])
|
||||
self.assertTrue(capabilities["safety"]["opencode_auto_approve"])
|
||||
self.assertTrue(capabilities["safety"]["trusted_clients_required"])
|
||||
self.assertNotIn("vault_path", json.dumps(capabilities))
|
||||
self.assertNotIn("runtime", json.dumps(capabilities).lower())
|
||||
self.assertNotIn("Bearer ", json.dumps(capabilities))
|
||||
self.assertNotIn("upload_token", json.dumps(capabilities))
|
||||
|
||||
def test_server_resource_read_honors_source_file_gate(self):
|
||||
class FakeMCP:
|
||||
resources = {}
|
||||
|
||||
def __init__(self, _name, **_kwargs):
|
||||
pass
|
||||
|
||||
def tool(self):
|
||||
return lambda func: func
|
||||
|
||||
def resource(self, template, **_kwargs):
|
||||
def decorator(func):
|
||||
self.resources[template] = func
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
def prompt(self, *_args, **_kwargs):
|
||||
return lambda func: func
|
||||
|
||||
with (
|
||||
tempfile.TemporaryDirectory() as tmp,
|
||||
patch.dict("sys.modules", {"mcp.server.fastmcp": type("Mod", (), {"FastMCP": FakeMCP})()}),
|
||||
patch.object(
|
||||
mcp_server,
|
||||
"read_file_asset_resource",
|
||||
return_value={"bytes": b"content"},
|
||||
) as read_resource,
|
||||
):
|
||||
root = Path(tmp)
|
||||
mcp_server.create_server(vault_path=root, allow_source_files=False)
|
||||
resource = FakeMCP.resources["asset://source/sha256/{digest}"]
|
||||
self.assertEqual(resource("a" * 64), b"content")
|
||||
self.assertFalse(read_resource.call_args.kwargs["allow_source_files"])
|
||||
|
||||
mcp_server.create_server(vault_path=root, allow_source_files=True)
|
||||
resource = FakeMCP.resources["asset://source/sha256/{digest}"]
|
||||
self.assertEqual(resource("b" * 64), b"content")
|
||||
self.assertTrue(read_resource.call_args.kwargs["allow_source_files"])
|
||||
|
||||
def test_readiness_tools_hide_local_paths(self):
|
||||
class FakeMCP:
|
||||
tools = {}
|
||||
|
||||
def __init__(self, _name, **_kwargs):
|
||||
pass
|
||||
|
||||
def tool(self):
|
||||
def decorator(func):
|
||||
self.tools[func.__name__] = func
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
def resource(self, *_args, **_kwargs):
|
||||
return lambda func: func
|
||||
|
||||
def prompt(self, *_args, **_kwargs):
|
||||
return lambda func: func
|
||||
|
||||
class FakeManager:
|
||||
def check_opencode_readiness(self, *, run_smoke_test=False):
|
||||
return {
|
||||
"ok": False,
|
||||
"checked_paths": ["/secret/project/.agents/skills/wiki-ingest/SKILL.md"],
|
||||
"errors": ["missing /secret/project/.agents/skills/wiki-ingest/SKILL.md"],
|
||||
}
|
||||
|
||||
def _public_preflight(self, result):
|
||||
return {"ok": result["ok"], "errors": ["missing <project> skill"]}
|
||||
|
||||
with (
|
||||
tempfile.TemporaryDirectory() as tmp,
|
||||
patch.dict("sys.modules", {"mcp.server.fastmcp": type("Mod", (), {"FastMCP": FakeMCP})()}),
|
||||
):
|
||||
mcp_server.create_server(
|
||||
vault_path=Path(tmp) / "vault",
|
||||
enable_ingest=True,
|
||||
ingest_manager=FakeManager(),
|
||||
)
|
||||
readiness = FakeMCP.tools["check_ingest_opencode_readiness"]()
|
||||
|
||||
self.assertNotIn("checked_paths", readiness)
|
||||
self.assertNotIn("/secret/project", json.dumps(readiness))
|
||||
|
||||
def test_create_server_passes_job_timeouts_to_managers(self):
|
||||
class FakeMCP:
|
||||
def __init__(self, _name, **_kwargs):
|
||||
pass
|
||||
|
||||
def tool(self):
|
||||
return lambda func: func
|
||||
|
||||
def resource(self, *_args, **_kwargs):
|
||||
return lambda func: func
|
||||
|
||||
def prompt(self, *_args, **_kwargs):
|
||||
return lambda func: func
|
||||
|
||||
with (
|
||||
tempfile.TemporaryDirectory() as tmp,
|
||||
patch.dict("sys.modules", {"mcp.server.fastmcp": type("Mod", (), {"FastMCP": FakeMCP})()}),
|
||||
patch.object(mcp_server, "IngestManager") as ingest_manager,
|
||||
patch.object(mcp_server, "QueryManager") as query_manager,
|
||||
):
|
||||
root = Path(tmp)
|
||||
mcp_server.create_server(
|
||||
vault_path=root / "vault",
|
||||
enable_ingest=True,
|
||||
enable_query=True,
|
||||
project_root=root / "project",
|
||||
ingest_runtime_root=root / "ingest",
|
||||
query_runtime_root=root / "query",
|
||||
ingest_job_timeout_seconds=17,
|
||||
query_job_timeout_seconds=23,
|
||||
)
|
||||
|
||||
self.assertEqual(ingest_manager.call_args.kwargs["job_timeout_seconds"], 17)
|
||||
self.assertEqual(query_manager.call_args.kwargs["job_timeout_seconds"], 23)
|
||||
|
||||
def test_get_mcp_capabilities_recommends_only_enabled_tool_groups(self):
|
||||
class FakeMCP:
|
||||
tools = {}
|
||||
|
||||
def __init__(self, _name, **_kwargs):
|
||||
pass
|
||||
|
||||
def tool(self):
|
||||
def decorator(func):
|
||||
self.tools[func.__name__] = func
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
def resource(self, *_args, **_kwargs):
|
||||
def decorator(func):
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
def prompt(self, *_args, **_kwargs):
|
||||
def decorator(func):
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
class FakeQueryManager:
|
||||
def ask_wiki(self, query, mode="normal", public_only=False, note=None, timeout_seconds=90, poll_interval_seconds=2):
|
||||
return {}
|
||||
|
||||
def start_query_job(self, query, mode="normal", public_only=False, note=None):
|
||||
return {}
|
||||
|
||||
def get_query_job_status(self, job_id):
|
||||
return {}
|
||||
|
||||
def get_query_job_result(self, job_id):
|
||||
return {}
|
||||
|
||||
def cancel_query_job(self, job_id):
|
||||
return {}
|
||||
|
||||
def check_opencode_readiness(self, *, run_smoke_test=False):
|
||||
return {}
|
||||
|
||||
with (
|
||||
tempfile.TemporaryDirectory() as tmp,
|
||||
patch.dict("sys.modules", {"mcp.server.fastmcp": type("Mod", (), {"FastMCP": FakeMCP})()}),
|
||||
):
|
||||
root = Path(tmp)
|
||||
mcp_server.create_server(
|
||||
vault_path=root / "vault",
|
||||
enable_file_assets=False,
|
||||
enable_ingest=False,
|
||||
enable_query=True,
|
||||
project_root=root / "project",
|
||||
query_runtime_root=root / "query-runtime",
|
||||
query_manager=FakeQueryManager(),
|
||||
)
|
||||
capabilities = FakeMCP.tools["get_mcp_capabilities"]()
|
||||
|
||||
first_steps = "\n".join(capabilities["recommended_first_steps"])
|
||||
self.assertEqual(capabilities["enabled_tools"], ["query"])
|
||||
self.assertIn("ask_wiki", first_steps)
|
||||
self.assertNotIn("search_file_assets", first_steps)
|
||||
self.assertNotIn("ingest_text_note", first_steps)
|
||||
self.assertNotIn("create_ingest_upload_ticket", first_steps)
|
||||
|
||||
def test_file_index_status_reports_opencode_model_settings(self):
|
||||
class FakeMCP:
|
||||
tools = {}
|
||||
@@ -795,6 +1340,61 @@ class RemoteMcpServerTests(unittest.TestCase):
|
||||
self.assertNotIn("Authorization: Bearer", instructions["http_upload"]["curl_example"])
|
||||
self.assertEqual(instructions["direct_text_tool"]["name"], "ingest_text_note")
|
||||
|
||||
def test_create_ingest_upload_ticket_prefers_explicit_upload_url(self):
|
||||
class FakeMCP:
|
||||
tools = {}
|
||||
|
||||
def __init__(self, _name, **_kwargs):
|
||||
pass
|
||||
|
||||
def tool(self):
|
||||
def decorator(func):
|
||||
self.tools[func.__name__] = func
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
def resource(self, *_args, **_kwargs):
|
||||
def decorator(func):
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
def prompt(self, *_args, **_kwargs):
|
||||
def decorator(func):
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
with (
|
||||
tempfile.TemporaryDirectory() as tmp,
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{"OBSIDIAN_MCP_INGEST_PUBLIC_UPLOAD_URL": "https://uploads.example.test/custom/upload"},
|
||||
clear=False,
|
||||
),
|
||||
patch.dict("sys.modules", {"mcp.server.fastmcp": type("Mod", (), {"FastMCP": FakeMCP})()}),
|
||||
):
|
||||
root = Path(tmp)
|
||||
mcp_server.create_server(
|
||||
vault_path=root / "vault",
|
||||
enable_ingest=True,
|
||||
project_root=root / "project",
|
||||
ingest_runtime_root=root / "runtime",
|
||||
public_base_url="https://wiki.example.test/proxy/mcp",
|
||||
)
|
||||
capabilities = FakeMCP.tools["get_mcp_capabilities"]()
|
||||
ticket = FakeMCP.tools["create_ingest_upload_ticket"](
|
||||
expires_seconds=60,
|
||||
max_files=1,
|
||||
start_job=False,
|
||||
)
|
||||
instructions = FakeMCP.tools["get_ingest_upload_instructions"]()
|
||||
|
||||
self.assertEqual(capabilities["ingest"]["http_upload_url"], "https://uploads.example.test/custom/upload")
|
||||
self.assertEqual(ticket["upload_url"], "https://uploads.example.test/custom/upload")
|
||||
self.assertEqual(instructions["http_upload"]["url"], "https://uploads.example.test/custom/upload")
|
||||
|
||||
def test_ingest_tools_validate_upload_and_preview_metadata(self):
|
||||
class FakeMCP:
|
||||
tools = {}
|
||||
@@ -1034,6 +1634,64 @@ class RemoteMcpServerTests(unittest.TestCase):
|
||||
)
|
||||
create_server.assert_not_called()
|
||||
|
||||
def test_main_forwards_managed_security_and_timeout_environment(self):
|
||||
with (
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"OBSIDIAN_MCP_TRANSPORT": "streamable-http",
|
||||
"OBSIDIAN_MCP_ALLOW_SOURCE_FILES": "1",
|
||||
"OBSIDIAN_MCP_ALLOWED_HOSTS": "wiki.example:*,10.0.0.2:*",
|
||||
"OBSIDIAN_MCP_ALLOWED_ORIGINS": "https://app.example",
|
||||
"OBSIDIAN_MCP_INGEST_JOB_TIMEOUT_SECONDS": "123",
|
||||
"OBSIDIAN_MCP_QUERY_JOB_TIMEOUT_SECONDS": "45",
|
||||
"OBSIDIAN_MCP_INGEST_MAX_REQUEST_BYTES": "999",
|
||||
},
|
||||
clear=True,
|
||||
),
|
||||
patch.object(mcp_server, "resolve_vault_path", return_value="/tmp/test-vault"),
|
||||
patch.object(mcp_server, "run_http_server") as run_http_server,
|
||||
):
|
||||
result = mcp_server.main([])
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
kwargs = run_http_server.call_args.kwargs
|
||||
self.assertTrue(kwargs["allow_source_files"])
|
||||
self.assertEqual(kwargs["allowed_hosts"], ["wiki.example:*", "10.0.0.2:*"])
|
||||
self.assertEqual(kwargs["allowed_origins"], ["https://app.example"])
|
||||
self.assertEqual(kwargs["ingest_job_timeout_seconds"], 123)
|
||||
self.assertEqual(kwargs["query_job_timeout_seconds"], 45)
|
||||
self.assertEqual(kwargs["ingest_max_request_bytes"], 999)
|
||||
|
||||
def test_main_closes_job_managers_after_stdio_server_stops(self):
|
||||
manager = type("Manager", (), {"close": unittest.mock.Mock()})()
|
||||
|
||||
class FakeServer:
|
||||
_obsidian_job_managers = (manager,)
|
||||
|
||||
def run(self, **_kwargs):
|
||||
return None
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, {"OBSIDIAN_MCP_TRANSPORT": "stdio"}, clear=True),
|
||||
patch.object(mcp_server, "resolve_vault_path", return_value="/tmp/test-vault"),
|
||||
patch.object(mcp_server, "create_server", return_value=FakeServer()),
|
||||
):
|
||||
result = mcp_server.main([])
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
manager.close.assert_called_once_with()
|
||||
|
||||
def test_close_server_managers_attempts_every_manager(self):
|
||||
first = type("Manager", (), {"close": lambda self: (_ for _ in ()).throw(OSError("disk full"))})()
|
||||
second = type("Manager", (), {"close": unittest.mock.Mock()})()
|
||||
server = type("Server", (), {"_obsidian_job_managers": (first, second)})()
|
||||
|
||||
errors = mcp_server.close_server_managers(server)
|
||||
|
||||
self.assertEqual(len(errors), 1)
|
||||
second.close.assert_called_once_with()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
+546
-2
@@ -45,12 +45,14 @@ class McpServiceConfigTests(unittest.TestCase):
|
||||
self.assertIn("/home/nac/.local/bin/uv run --no-sync python -m obsidian_wiki.mcp_server", unit)
|
||||
self.assertIn("--vault /data/bjd-vault", unit)
|
||||
self.assertIn("--transport streamable-http", unit)
|
||||
self.assertIn("--host 0.0.0.0", unit)
|
||||
self.assertIn("--port 8765", unit)
|
||||
self.assertNotIn("--host", unit)
|
||||
self.assertNotIn("--port", unit)
|
||||
self.assertNotIn("--path", unit)
|
||||
self.assertIn("PassEnvironment=http_proxy https_proxy all_proxy no_proxy HTTP_PROXY HTTPS_PROXY ALL_PROXY NO_PROXY", unit)
|
||||
self.assertIn("MemoryMax=2G", unit)
|
||||
self.assertIn("Restart=on-failure", unit)
|
||||
self.assertNotIn("OBSIDIAN_MCP_TOKEN=", unit)
|
||||
self.assertNotIn("readonly", unit.lower())
|
||||
|
||||
def test_write_token_env_file_is_private(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
@@ -63,7 +65,12 @@ class McpServiceConfigTests(unittest.TestCase):
|
||||
port=8765,
|
||||
path="/mcp",
|
||||
public_base_url="http://192.168.1.10:8765/mcp",
|
||||
public_upload_url="https://uploads.example.test/custom/upload",
|
||||
enable_ingest=True,
|
||||
allow_source_files=False,
|
||||
ingest_job_timeout_seconds=111,
|
||||
query_job_timeout_seconds=22,
|
||||
ingest_max_request_bytes=333,
|
||||
)
|
||||
|
||||
content = env_path.read_text(encoding="utf-8")
|
||||
@@ -72,15 +79,112 @@ class McpServiceConfigTests(unittest.TestCase):
|
||||
self.assertIn("OBSIDIAN_MCP_PORT=8765\n", content)
|
||||
self.assertIn("OBSIDIAN_MCP_PATH=/mcp\n", content)
|
||||
self.assertIn("OBSIDIAN_MCP_PUBLIC_BASE_URL=http://192.168.1.10:8765/mcp\n", content)
|
||||
self.assertIn("OBSIDIAN_MCP_INGEST_PUBLIC_UPLOAD_URL=https://uploads.example.test/custom/upload\n", content)
|
||||
self.assertIn("OBSIDIAN_MCP_ALLOW_SOURCE_FILES=0\n", content)
|
||||
self.assertIn("OBSIDIAN_MCP_ALLOWED_HOSTS=", content)
|
||||
self.assertIn("192.168.1.10:*", content)
|
||||
self.assertIn("OBSIDIAN_MCP_ALLOWED_ORIGINS=", content)
|
||||
self.assertIn("http://192.168.1.10:8765", content)
|
||||
self.assertIn("https://uploads.example.test", content)
|
||||
self.assertIn("OBSIDIAN_MCP_ENABLED_TOOLS=file-assets,ingest\n", content)
|
||||
self.assertIn("OBSIDIAN_MCP_ENABLE_INGEST=1\n", content)
|
||||
self.assertIn("OBSIDIAN_MCP_INGEST_OPENCODE_MODEL=openai/gpt-5.5\n", content)
|
||||
self.assertIn("OBSIDIAN_MCP_INGEST_OPENCODE_VARIANT=high\n", content)
|
||||
self.assertIn("OBSIDIAN_MCP_QUERY_OPENCODE_MODEL=openai/gpt-5.5\n", content)
|
||||
self.assertIn("OBSIDIAN_MCP_QUERY_OPENCODE_VARIANT=minimal\n", content)
|
||||
self.assertIn("OBSIDIAN_MCP_INGEST_JOB_TIMEOUT_SECONDS=111\n", content)
|
||||
self.assertIn("OBSIDIAN_MCP_QUERY_JOB_TIMEOUT_SECONDS=22\n", content)
|
||||
self.assertIn("OBSIDIAN_MCP_INGEST_MAX_REQUEST_BYTES=333\n", content)
|
||||
self.assertEqual(stat.S_IMODE(os.stat(env_path).st_mode), 0o600)
|
||||
self.assertEqual(stat.S_IMODE(os.stat(env_path.parent).st_mode), 0o700)
|
||||
|
||||
def test_saved_service_config_returns_dataclass_and_preserves_explicit_source_file_setting(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
project_root = Path(tmp) / "project"
|
||||
env_path = project_root / ".runtime" / "obsidian-wiki-mcp.env"
|
||||
manage_mcp_service.write_token_env_file(
|
||||
env_path,
|
||||
"saved-token",
|
||||
host="127.0.0.1",
|
||||
port=9988,
|
||||
path="/saved-mcp",
|
||||
public_base_url="https://wiki.example.test/saved-mcp",
|
||||
public_upload_url="https://uploads.example.test/saved",
|
||||
enabled_tools=("file-assets", "query"),
|
||||
allow_source_files=False,
|
||||
ingest_job_timeout_seconds=444,
|
||||
query_job_timeout_seconds=55,
|
||||
ingest_max_request_bytes=666,
|
||||
)
|
||||
|
||||
config = manage_mcp_service.saved_service_config(project_root)
|
||||
|
||||
self.assertIsInstance(config, manage_mcp_service.ServiceConfig)
|
||||
self.assertEqual(config.token, "saved-token")
|
||||
self.assertEqual(config.host, "127.0.0.1")
|
||||
self.assertEqual(config.port, 9988)
|
||||
self.assertEqual(config.path, "/saved-mcp")
|
||||
self.assertEqual(config.public_base_url, "https://wiki.example.test/saved-mcp")
|
||||
self.assertEqual(config.public_upload_url, "https://uploads.example.test/saved")
|
||||
self.assertEqual(config.enabled_tools, ("file-assets", "query"))
|
||||
self.assertFalse(config.allow_source_files)
|
||||
self.assertEqual(config.ingest_job_timeout_seconds, 444)
|
||||
self.assertEqual(config.query_job_timeout_seconds, 55)
|
||||
self.assertEqual(config.ingest_max_request_bytes, 666)
|
||||
self.assertIn("wiki.example.test:*", config.allowed_hosts)
|
||||
self.assertIn("uploads.example.test:*", config.allowed_hosts)
|
||||
self.assertIn("https://wiki.example.test", config.allowed_origins)
|
||||
self.assertIn("https://uploads.example.test", config.allowed_origins)
|
||||
|
||||
def test_saved_service_config_migrates_legacy_source_file_setting(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
project_root = Path(tmp) / "project"
|
||||
env_path = project_root / ".runtime" / "obsidian-wiki-mcp.env"
|
||||
env_path.parent.mkdir(parents=True)
|
||||
env_path.write_text(
|
||||
"OBSIDIAN_MCP_TOKEN=saved-token\n"
|
||||
"OBSIDIAN_MCP_ENABLED_TOOLS=file-assets,query\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
config = manage_mcp_service.saved_service_config(project_root)
|
||||
|
||||
self.assertTrue(config.allow_source_files)
|
||||
|
||||
env_path.write_text(
|
||||
"OBSIDIAN_MCP_TOKEN=saved-token\n"
|
||||
"OBSIDIAN_MCP_ENABLED_TOOLS=query\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
config = manage_mcp_service.saved_service_config(project_root)
|
||||
self.assertFalse(config.allow_source_files)
|
||||
|
||||
def test_ensure_service_config_returns_paths_and_default_service_config(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
project_root = Path(tmp) / "project"
|
||||
project_root.mkdir()
|
||||
config_home = Path(tmp) / "config"
|
||||
with (
|
||||
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
|
||||
patch.object(manage_mcp_service, "generate_token", return_value="created-token"),
|
||||
patch.object(manage_mcp_service, "detect_remote_hosts", return_value=["10.0.0.2"]),
|
||||
):
|
||||
paths, config = manage_mcp_service.ensure_service_config(project_root)
|
||||
|
||||
self.assertIsInstance(paths, manage_mcp_service.ServicePaths)
|
||||
self.assertIsInstance(config, manage_mcp_service.ServiceConfig)
|
||||
self.assertEqual(config.host, "0.0.0.0")
|
||||
self.assertEqual(config.port, 8765)
|
||||
self.assertEqual(config.path, "/mcp")
|
||||
self.assertEqual(config.public_base_url, "http://10.0.0.2:8765/mcp")
|
||||
self.assertFalse(config.allow_source_files)
|
||||
self.assertIn("10.0.0.2:*", config.allowed_hosts)
|
||||
self.assertIn("http://10.0.0.2:8765", config.allowed_origins)
|
||||
self.assertIn(
|
||||
"OBSIDIAN_MCP_ALLOW_SOURCE_FILES=0\n",
|
||||
paths.env_path.read_text(encoding="utf-8"),
|
||||
)
|
||||
|
||||
def test_resolve_vault_for_service_uses_explicit_or_configured_vault(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
project_root = Path(tmp) / "project"
|
||||
@@ -115,6 +219,17 @@ class McpServiceConfigTests(unittest.TestCase):
|
||||
self.assertIn('"wiki-file-assets"', output)
|
||||
self.assertNotIn('"obsidian-wiki"', output)
|
||||
|
||||
def test_remote_urls_bracket_ipv6_hosts(self):
|
||||
self.assertEqual(
|
||||
manage_mcp_service.public_base_url_from_hosts(["2001:db8::1"]),
|
||||
"http://[2001:db8::1]:8765/mcp",
|
||||
)
|
||||
output = manage_mcp_service.render_remote_config(
|
||||
hosts=["2001:db8::1"],
|
||||
token="secret-token",
|
||||
)
|
||||
self.assertIn('"url": "http://[2001:db8::1]:8765/mcp"', output)
|
||||
|
||||
def test_render_remote_config_prints_ingest_upload_endpoint_when_enabled(self):
|
||||
output = manage_mcp_service.render_remote_config(
|
||||
hosts=["192.168.1.10", "10.0.0.2"],
|
||||
@@ -143,6 +258,41 @@ class McpServiceConfigTests(unittest.TestCase):
|
||||
self.assertIn("https://wiki.example.test/mcp-ingest/uploads", output)
|
||||
self.assertNotIn("http://192.168.1.10:8765/mcp-ingest/uploads", output)
|
||||
|
||||
def test_render_remote_config_uses_normalized_public_url_as_primary_client_endpoint(self):
|
||||
output = manage_mcp_service.render_remote_config(
|
||||
hosts=["192.168.1.10", "10.0.0.2"],
|
||||
token="test-token",
|
||||
port=8765,
|
||||
path="/mcp",
|
||||
public_base_url=" https://wiki.example.test/proxy/mcp/?ignored=yes#fragment ",
|
||||
)
|
||||
|
||||
self.assertIn(
|
||||
"Use this URL from another machine:\nhttps://wiki.example.test/proxy/mcp\n",
|
||||
output,
|
||||
)
|
||||
self.assertIn('"url": "https://wiki.example.test/proxy/mcp"', output)
|
||||
self.assertIn(
|
||||
"Other detected addresses:\n"
|
||||
"- http://192.168.1.10:8765/mcp\n"
|
||||
"- http://10.0.0.2:8765/mcp\n",
|
||||
output,
|
||||
)
|
||||
|
||||
def test_render_remote_config_prefers_explicit_upload_url(self):
|
||||
output = manage_mcp_service.render_remote_config(
|
||||
hosts=["192.168.1.10"],
|
||||
token="test-token",
|
||||
port=8765,
|
||||
path="/mcp",
|
||||
public_base_url="https://wiki.example.test/proxy/mcp",
|
||||
public_upload_url="https://uploads.example.test/custom/upload",
|
||||
enabled_tools=("file-assets", "ingest"),
|
||||
)
|
||||
|
||||
self.assertIn("https://uploads.example.test/custom/upload", output)
|
||||
self.assertNotIn("https://wiki.example.test/mcp-ingest/uploads", output)
|
||||
|
||||
def test_new_token_writes_token_without_touching_systemd_when_unit_missing(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
project_root = Path(tmp) / "project"
|
||||
@@ -160,6 +310,7 @@ class McpServiceConfigTests(unittest.TestCase):
|
||||
env_content = env_path.read_text(encoding="utf-8")
|
||||
self.assertIn("OBSIDIAN_MCP_TOKEN=new-token\n", env_content)
|
||||
self.assertIn("OBSIDIAN_MCP_PORT=8765\n", env_content)
|
||||
self.assertIn("OBSIDIAN_MCP_ALLOW_SOURCE_FILES=0\n", env_content)
|
||||
self.assertIn("http://10.0.0.2:8765/mcp", output)
|
||||
run_systemctl.assert_not_called()
|
||||
|
||||
@@ -184,6 +335,158 @@ class McpServiceConfigTests(unittest.TestCase):
|
||||
self.assertIn("http://10.0.0.2:8765/mcp", output)
|
||||
run_systemctl.assert_called_once_with(["restart", manage_mcp_service.SERVICE_NAME])
|
||||
|
||||
def test_new_token_preserves_saved_service_config_when_endpoint_arguments_are_omitted(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
project_root = Path(tmp) / "project"
|
||||
project_root.mkdir()
|
||||
config_home = Path(tmp) / "config"
|
||||
env_path = project_root / ".runtime" / "obsidian-wiki-mcp.env"
|
||||
manage_mcp_service.write_token_env_file(
|
||||
env_path,
|
||||
"old-token",
|
||||
host="127.0.0.1",
|
||||
port=9988,
|
||||
path="/saved-mcp",
|
||||
public_base_url="https://wiki.example.test/saved-mcp",
|
||||
public_upload_url="https://uploads.example.test/saved",
|
||||
enabled_tools=("file-assets", "query"),
|
||||
allow_source_files=False,
|
||||
)
|
||||
|
||||
with (
|
||||
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
|
||||
patch.object(manage_mcp_service, "generate_token", return_value="new-token"),
|
||||
patch.object(manage_mcp_service, "detect_remote_hosts", return_value=["10.0.0.2"]),
|
||||
patch.object(manage_mcp_service, "run_systemctl") as run_systemctl,
|
||||
):
|
||||
output = manage_mcp_service.new_token(project_root=project_root)
|
||||
|
||||
config = manage_mcp_service.saved_service_config(project_root)
|
||||
self.assertEqual(config.token, "new-token")
|
||||
self.assertEqual(config.host, "127.0.0.1")
|
||||
self.assertEqual(config.port, 9988)
|
||||
self.assertEqual(config.path, "/saved-mcp")
|
||||
self.assertEqual(config.public_base_url, "https://wiki.example.test/saved-mcp")
|
||||
self.assertEqual(config.public_upload_url, "https://uploads.example.test/saved")
|
||||
self.assertEqual(config.enabled_tools, ("file-assets", "query"))
|
||||
self.assertFalse(config.allow_source_files)
|
||||
self.assertIn('"url": "https://wiki.example.test/saved-mcp"', output)
|
||||
run_systemctl.assert_not_called()
|
||||
|
||||
def test_new_token_only_changes_token_when_saved_public_urls_are_empty(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
project_root = Path(tmp) / "project"
|
||||
project_root.mkdir()
|
||||
config_home = Path(tmp) / "config"
|
||||
env_path = project_root / ".runtime" / "obsidian-wiki-mcp.env"
|
||||
manage_mcp_service.write_token_env_file(
|
||||
env_path,
|
||||
"old-token",
|
||||
host="127.0.0.1",
|
||||
port=9988,
|
||||
path="/saved-mcp",
|
||||
allow_source_files=False,
|
||||
)
|
||||
original_lines = env_path.read_text(encoding="utf-8").splitlines()
|
||||
|
||||
with (
|
||||
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
|
||||
patch.object(manage_mcp_service, "generate_token", return_value="new-token"),
|
||||
patch.object(manage_mcp_service, "detect_remote_hosts", return_value=["10.0.0.2"]),
|
||||
patch.object(manage_mcp_service, "run_systemctl"),
|
||||
):
|
||||
output = manage_mcp_service.new_token(project_root=project_root)
|
||||
|
||||
updated_lines = env_path.read_text(encoding="utf-8").splitlines()
|
||||
self.assertEqual(updated_lines[1:], original_lines[1:])
|
||||
self.assertEqual(updated_lines[0], "OBSIDIAN_MCP_TOKEN=new-token")
|
||||
self.assertNotIn("OBSIDIAN_MCP_PUBLIC_BASE_URL=", "\n".join(updated_lines))
|
||||
self.assertIn('"url": "http://10.0.0.2:9988/saved-mcp"', output)
|
||||
|
||||
def test_new_token_explicit_endpoint_and_public_urls_override_saved_config(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
project_root = Path(tmp) / "project"
|
||||
project_root.mkdir()
|
||||
config_home = Path(tmp) / "config"
|
||||
manage_mcp_service.write_token_env_file(
|
||||
project_root / ".runtime" / "obsidian-wiki-mcp.env",
|
||||
"old-token",
|
||||
host="127.0.0.1",
|
||||
port=9988,
|
||||
path="/saved-mcp",
|
||||
public_base_url="https://wiki.example.test/saved-mcp",
|
||||
public_upload_url="https://uploads.example.test/saved",
|
||||
)
|
||||
|
||||
with (
|
||||
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
|
||||
patch.object(manage_mcp_service, "generate_token", return_value="new-token"),
|
||||
patch.object(manage_mcp_service, "detect_remote_hosts", return_value=["10.0.0.2"]),
|
||||
patch.object(manage_mcp_service, "run_systemctl"),
|
||||
):
|
||||
output = manage_mcp_service.new_token(
|
||||
project_root=project_root,
|
||||
host="0.0.0.0",
|
||||
port=8877,
|
||||
path="/new-mcp",
|
||||
public_base_url="https://new.example.test/api/mcp/",
|
||||
public_upload_url="https://uploads-new.example.test/api/upload/",
|
||||
)
|
||||
|
||||
config = manage_mcp_service.saved_service_config(project_root)
|
||||
self.assertEqual(config.host, "0.0.0.0")
|
||||
self.assertEqual(config.port, 8877)
|
||||
self.assertEqual(config.path, "/new-mcp")
|
||||
self.assertEqual(config.public_base_url, "https://new.example.test/api/mcp")
|
||||
self.assertEqual(config.public_upload_url, "https://uploads-new.example.test/api/upload")
|
||||
self.assertIn("new.example.test", config.allowed_hosts)
|
||||
self.assertIn("uploads-new.example.test", config.allowed_hosts)
|
||||
self.assertIn("https://new.example.test", config.allowed_origins)
|
||||
self.assertIn("https://uploads-new.example.test", config.allowed_origins)
|
||||
self.assertIn('"url": "https://new.example.test/api/mcp"', output)
|
||||
|
||||
def test_new_token_recomputes_public_url_when_only_endpoint_changes(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
project_root = Path(tmp) / "project"
|
||||
project_root.mkdir()
|
||||
manage_mcp_service.write_token_env_file(
|
||||
project_root / ".runtime" / "obsidian-wiki-mcp.env",
|
||||
"old-token",
|
||||
host="127.0.0.1",
|
||||
port=9988,
|
||||
path="/saved-mcp",
|
||||
public_base_url="https://wiki.example.test/saved-mcp",
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(manage_mcp_service, "generate_token", return_value="new-token"),
|
||||
patch.object(manage_mcp_service, "detect_remote_hosts", return_value=["10.0.0.2"]),
|
||||
patch.object(manage_mcp_service, "run_systemctl"),
|
||||
):
|
||||
manage_mcp_service.new_token(
|
||||
project_root=project_root,
|
||||
port=8877,
|
||||
path="/new-mcp",
|
||||
)
|
||||
|
||||
config = manage_mcp_service.saved_service_config(project_root)
|
||||
self.assertEqual(config.public_base_url, "http://127.0.0.1:8877/new-mcp")
|
||||
|
||||
def test_new_token_first_install_uses_concrete_bind_host_as_public_url(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
project_root = Path(tmp) / "project"
|
||||
project_root.mkdir()
|
||||
|
||||
with (
|
||||
patch.object(manage_mcp_service, "generate_token", return_value="new-token"),
|
||||
patch.object(manage_mcp_service, "detect_remote_hosts", return_value=["10.0.0.2"]),
|
||||
patch.object(manage_mcp_service, "run_systemctl"),
|
||||
):
|
||||
manage_mcp_service.new_token(project_root=project_root, host="127.0.0.1")
|
||||
|
||||
config = manage_mcp_service.saved_service_config(project_root)
|
||||
self.assertEqual(config.public_base_url, "http://127.0.0.1:8765/mcp")
|
||||
|
||||
def test_start_service_uses_saved_token_and_restarts_service(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
project_root = Path(tmp) / "project"
|
||||
@@ -216,6 +519,8 @@ class McpServiceConfigTests(unittest.TestCase):
|
||||
self.assertIn("Bearer saved-token", output)
|
||||
env_content = (project_root / ".runtime" / "obsidian-wiki-mcp.env").read_text(encoding="utf-8")
|
||||
self.assertIn("OBSIDIAN_MCP_ENABLED_TOOLS=file-assets\n", env_content)
|
||||
self.assertNotIn("OBSIDIAN_MCP_PUBLIC_BASE_URL=", env_content)
|
||||
self.assertNotIn("OBSIDIAN_MCP_INGEST_PUBLIC_UPLOAD_URL=", env_content)
|
||||
generate_token.assert_not_called()
|
||||
prepare_runtime.assert_called_once_with(project_root, uv_path=Path("/home/nac/.local/bin/uv"))
|
||||
run_systemctl.assert_has_calls(
|
||||
@@ -226,6 +531,156 @@ class McpServiceConfigTests(unittest.TestCase):
|
||||
]
|
||||
)
|
||||
|
||||
def test_start_service_preserves_saved_endpoint_and_public_urls_when_arguments_are_omitted(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
project_root = Path(tmp) / "project"
|
||||
project_root.mkdir()
|
||||
config_home = Path(tmp) / "config"
|
||||
env_path = project_root / ".runtime" / "obsidian-wiki-mcp.env"
|
||||
manage_mcp_service.write_token_env_file(
|
||||
env_path,
|
||||
"saved-token",
|
||||
host="127.0.0.1",
|
||||
port=9988,
|
||||
path="/saved-mcp",
|
||||
public_base_url="https://wiki.example.test/saved-mcp",
|
||||
public_upload_url="https://uploads.example.test/saved",
|
||||
allow_source_files=False,
|
||||
)
|
||||
|
||||
with (
|
||||
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
|
||||
patch.object(manage_mcp_service, "detect_remote_hosts", return_value=["10.0.0.2"]),
|
||||
patch.object(manage_mcp_service, "find_uv_path", return_value=Path("/home/nac/.local/bin/uv")),
|
||||
patch.object(manage_mcp_service, "resolve_vault_for_service", return_value=Path("/data/bjd-vault")),
|
||||
patch.object(manage_mcp_service, "prepare_runtime"),
|
||||
patch.object(manage_mcp_service, "run_systemctl"),
|
||||
):
|
||||
output = manage_mcp_service.start_service(project_root=project_root)
|
||||
|
||||
config = manage_mcp_service.saved_service_config(project_root)
|
||||
self.assertEqual(config.host, "127.0.0.1")
|
||||
self.assertEqual(config.port, 9988)
|
||||
self.assertEqual(config.path, "/saved-mcp")
|
||||
self.assertEqual(config.public_base_url, "https://wiki.example.test/saved-mcp")
|
||||
self.assertEqual(config.public_upload_url, "https://uploads.example.test/saved")
|
||||
self.assertFalse(config.allow_source_files)
|
||||
unit_content = (
|
||||
config_home / "systemd" / "user" / manage_mcp_service.SERVICE_NAME
|
||||
).read_text(encoding="utf-8")
|
||||
self.assertNotIn("--host", unit_content)
|
||||
env_content = env_path.read_text(encoding="utf-8")
|
||||
self.assertIn("OBSIDIAN_MCP_HOST=127.0.0.1", env_content)
|
||||
self.assertIn("OBSIDIAN_MCP_PORT=9988", env_content)
|
||||
self.assertIn("OBSIDIAN_MCP_PATH=/saved-mcp", env_content)
|
||||
self.assertIn('"url": "https://wiki.example.test/saved-mcp"', output)
|
||||
|
||||
def test_start_service_explicit_arguments_override_saved_config(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
project_root = Path(tmp) / "project"
|
||||
project_root.mkdir()
|
||||
config_home = Path(tmp) / "config"
|
||||
manage_mcp_service.write_token_env_file(
|
||||
project_root / ".runtime" / "obsidian-wiki-mcp.env",
|
||||
"saved-token",
|
||||
host="127.0.0.1",
|
||||
port=9988,
|
||||
path="/saved-mcp",
|
||||
public_base_url="https://wiki.example.test/saved-mcp",
|
||||
public_upload_url="https://uploads.example.test/saved",
|
||||
allow_source_files=False,
|
||||
)
|
||||
|
||||
with (
|
||||
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
|
||||
patch.object(manage_mcp_service, "detect_remote_hosts", return_value=["10.0.0.2"]),
|
||||
patch.object(manage_mcp_service, "find_uv_path", return_value=Path("/home/nac/.local/bin/uv")),
|
||||
patch.object(manage_mcp_service, "resolve_vault_for_service", return_value=Path("/data/bjd-vault")),
|
||||
patch.object(manage_mcp_service, "prepare_runtime"),
|
||||
patch.object(manage_mcp_service, "run_systemctl"),
|
||||
):
|
||||
output = manage_mcp_service.start_service(
|
||||
project_root=project_root,
|
||||
host="0.0.0.0",
|
||||
port=8877,
|
||||
path="/new-mcp",
|
||||
public_base_url="https://new.example.test/api/mcp/",
|
||||
public_upload_url="https://uploads-new.example.test/api/upload/",
|
||||
allow_source_files=True,
|
||||
)
|
||||
|
||||
config = manage_mcp_service.saved_service_config(project_root)
|
||||
self.assertEqual(config.host, "0.0.0.0")
|
||||
self.assertEqual(config.port, 8877)
|
||||
self.assertEqual(config.path, "/new-mcp")
|
||||
self.assertEqual(config.public_base_url, "https://new.example.test/api/mcp")
|
||||
self.assertEqual(config.public_upload_url, "https://uploads-new.example.test/api/upload")
|
||||
self.assertTrue(config.allow_source_files)
|
||||
self.assertIn("new.example.test", config.allowed_hosts)
|
||||
self.assertIn("uploads-new.example.test", config.allowed_hosts)
|
||||
self.assertIn("https://new.example.test", config.allowed_origins)
|
||||
self.assertIn("https://uploads-new.example.test", config.allowed_origins)
|
||||
unit_content = (
|
||||
config_home / "systemd" / "user" / manage_mcp_service.SERVICE_NAME
|
||||
).read_text(encoding="utf-8")
|
||||
self.assertNotIn("--host", unit_content)
|
||||
env_content = (project_root / ".runtime" / "obsidian-wiki-mcp.env").read_text(encoding="utf-8")
|
||||
self.assertIn("OBSIDIAN_MCP_HOST=0.0.0.0", env_content)
|
||||
self.assertIn("OBSIDIAN_MCP_PORT=8877", env_content)
|
||||
self.assertIn("OBSIDIAN_MCP_PATH=/new-mcp", env_content)
|
||||
self.assertIn('"url": "https://new.example.test/api/mcp"', output)
|
||||
|
||||
def test_start_service_recomputes_public_url_when_only_endpoint_changes(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
project_root = Path(tmp) / "project"
|
||||
project_root.mkdir()
|
||||
config_home = Path(tmp) / "config"
|
||||
manage_mcp_service.write_token_env_file(
|
||||
project_root / ".runtime" / "obsidian-wiki-mcp.env",
|
||||
"saved-token",
|
||||
host="127.0.0.1",
|
||||
port=9988,
|
||||
path="/saved-mcp",
|
||||
public_base_url="https://wiki.example.test/saved-mcp",
|
||||
)
|
||||
|
||||
with (
|
||||
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
|
||||
patch.object(manage_mcp_service, "detect_remote_hosts", return_value=["10.0.0.2"]),
|
||||
patch.object(manage_mcp_service, "find_uv_path", return_value=Path("/home/nac/.local/bin/uv")),
|
||||
patch.object(manage_mcp_service, "resolve_vault_for_service", return_value=Path("/data/bjd-vault")),
|
||||
patch.object(manage_mcp_service, "prepare_runtime"),
|
||||
patch.object(manage_mcp_service, "run_systemctl"),
|
||||
):
|
||||
manage_mcp_service.start_service(
|
||||
project_root=project_root,
|
||||
port=8877,
|
||||
path="/new-mcp",
|
||||
)
|
||||
|
||||
config = manage_mcp_service.saved_service_config(project_root)
|
||||
self.assertEqual(config.public_base_url, "http://127.0.0.1:8877/new-mcp")
|
||||
|
||||
def test_start_service_first_install_uses_concrete_bind_host_as_public_url(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
project_root = Path(tmp) / "project"
|
||||
project_root.mkdir()
|
||||
config_home = Path(tmp) / "config"
|
||||
|
||||
with (
|
||||
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
|
||||
patch.object(manage_mcp_service, "generate_token", return_value="created-token"),
|
||||
patch.object(manage_mcp_service, "detect_remote_hosts", return_value=["10.0.0.2"]),
|
||||
patch.object(manage_mcp_service, "find_uv_path", return_value=Path("/home/nac/.local/bin/uv")),
|
||||
patch.object(manage_mcp_service, "resolve_vault_for_service", return_value=Path("/data/bjd-vault")),
|
||||
patch.object(manage_mcp_service, "prepare_runtime"),
|
||||
patch.object(manage_mcp_service, "run_systemctl"),
|
||||
):
|
||||
manage_mcp_service.start_service(project_root=project_root, host="127.0.0.1")
|
||||
|
||||
config = manage_mcp_service.saved_service_config(project_root)
|
||||
self.assertEqual(config.public_base_url, "http://127.0.0.1:8765/mcp")
|
||||
|
||||
def test_start_service_generates_token_when_missing_and_prints_config(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
project_root = Path(tmp) / "project"
|
||||
@@ -251,9 +706,11 @@ class McpServiceConfigTests(unittest.TestCase):
|
||||
env_path = project_root / ".runtime" / "obsidian-wiki-mcp.env"
|
||||
env_content = env_path.read_text(encoding="utf-8")
|
||||
self.assertIn("OBSIDIAN_MCP_TOKEN=created-token\n", env_content)
|
||||
self.assertIn("OBSIDIAN_MCP_HOST=0.0.0.0\n", env_content)
|
||||
self.assertIn("OBSIDIAN_MCP_PORT=9876\n", env_content)
|
||||
self.assertIn("OBSIDIAN_MCP_PATH=/wiki-mcp\n", env_content)
|
||||
self.assertIn("OBSIDIAN_MCP_PUBLIC_BASE_URL=http://10.0.0.4:9876/wiki-mcp\n", env_content)
|
||||
self.assertIn("OBSIDIAN_MCP_ALLOW_SOURCE_FILES=0\n", env_content)
|
||||
self.assertIn("OBSIDIAN_MCP_ENABLED_TOOLS=file-assets,ingest\n", env_content)
|
||||
self.assertIn("OBSIDIAN_MCP_ENABLE_INGEST=1\n", env_content)
|
||||
self.assertIn("OBSIDIAN_MCP_INGEST_OPENCODE_PATH=/home/nac/.opencode/bin/opencode\n", env_content)
|
||||
@@ -301,6 +758,11 @@ class McpServiceConfigTests(unittest.TestCase):
|
||||
+ "OBSIDIAN_MCP_PUBLIC_BASE_URL=https://wiki.example.test/mcp\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
env_path.write_text(
|
||||
env_path.read_text(encoding="utf-8")
|
||||
+ "OBSIDIAN_MCP_INGEST_PUBLIC_UPLOAD_URL=https://uploads.example.test/custom/upload\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with (
|
||||
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
|
||||
@@ -321,6 +783,7 @@ class McpServiceConfigTests(unittest.TestCase):
|
||||
self.assertIn("OBSIDIAN_MCP_QUERY_OPENCODE_MODEL=openai/custom-fast\n", env_content)
|
||||
self.assertIn("OBSIDIAN_MCP_QUERY_OPENCODE_VARIANT=low\n", env_content)
|
||||
self.assertIn("OBSIDIAN_MCP_PUBLIC_BASE_URL=https://wiki.example.test/mcp\n", env_content)
|
||||
self.assertIn("OBSIDIAN_MCP_INGEST_PUBLIC_UPLOAD_URL=https://uploads.example.test/custom/upload\n", env_content)
|
||||
|
||||
def test_start_service_can_enable_query_without_ingest(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
@@ -496,6 +959,82 @@ class McpServiceConfigTests(unittest.TestCase):
|
||||
self.assertTrue(start_service.call_args.kwargs["enable_query"])
|
||||
print_fn.assert_called_once_with("config\n", end="")
|
||||
|
||||
def test_main_start_passes_none_for_omitted_overrides_and_source_file_policy_flags(self):
|
||||
with (
|
||||
patch.object(manage_mcp_service, "start_service", return_value="config\n") as start_service,
|
||||
patch("builtins.print"),
|
||||
):
|
||||
status = manage_mcp_service.main(["start", "--deny-source-files"])
|
||||
|
||||
self.assertEqual(status, 0)
|
||||
kwargs = start_service.call_args.kwargs
|
||||
self.assertIsNone(kwargs["host"])
|
||||
self.assertIsNone(kwargs["port"])
|
||||
self.assertIsNone(kwargs["path"])
|
||||
self.assertIsNone(kwargs["public_base_url"])
|
||||
self.assertIsNone(kwargs["public_upload_url"])
|
||||
self.assertFalse(kwargs["allow_source_files"])
|
||||
|
||||
with (
|
||||
patch.object(manage_mcp_service, "start_service", return_value="config\n") as start_service,
|
||||
patch("builtins.print"),
|
||||
):
|
||||
status = manage_mcp_service.main(["start", "--allow-source-files"])
|
||||
|
||||
self.assertEqual(status, 0)
|
||||
self.assertTrue(start_service.call_args.kwargs["allow_source_files"])
|
||||
|
||||
def test_main_passes_explicit_endpoint_and_public_url_overrides(self):
|
||||
endpoint_args = [
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
"9988",
|
||||
"--path",
|
||||
"/custom-mcp",
|
||||
"--public-base-url",
|
||||
"https://wiki.example.test/custom-mcp",
|
||||
"--public-upload-url",
|
||||
"https://uploads.example.test/custom",
|
||||
]
|
||||
with (
|
||||
patch.object(manage_mcp_service, "start_service", return_value="config\n") as start_service,
|
||||
patch("builtins.print"),
|
||||
):
|
||||
start_status = manage_mcp_service.main(["start", *endpoint_args])
|
||||
|
||||
self.assertEqual(start_status, 0)
|
||||
self.assertEqual(start_service.call_args.kwargs["host"], "127.0.0.1")
|
||||
self.assertEqual(start_service.call_args.kwargs["port"], 9988)
|
||||
self.assertEqual(start_service.call_args.kwargs["path"], "/custom-mcp")
|
||||
self.assertEqual(
|
||||
start_service.call_args.kwargs["public_base_url"],
|
||||
"https://wiki.example.test/custom-mcp",
|
||||
)
|
||||
self.assertEqual(
|
||||
start_service.call_args.kwargs["public_upload_url"],
|
||||
"https://uploads.example.test/custom",
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(manage_mcp_service, "new_token", return_value="config\n") as new_token,
|
||||
patch("builtins.print"),
|
||||
):
|
||||
token_status = manage_mcp_service.main(["new-token", *endpoint_args])
|
||||
|
||||
self.assertEqual(token_status, 0)
|
||||
self.assertEqual(new_token.call_args.kwargs["host"], "127.0.0.1")
|
||||
self.assertEqual(new_token.call_args.kwargs["port"], 9988)
|
||||
self.assertEqual(new_token.call_args.kwargs["path"], "/custom-mcp")
|
||||
self.assertEqual(
|
||||
new_token.call_args.kwargs["public_base_url"],
|
||||
"https://wiki.example.test/custom-mcp",
|
||||
)
|
||||
self.assertEqual(
|
||||
new_token.call_args.kwargs["public_upload_url"],
|
||||
"https://uploads.example.test/custom",
|
||||
)
|
||||
|
||||
def test_main_new_token_does_not_expect_enable_ingest_argument(self):
|
||||
with (
|
||||
patch.object(manage_mcp_service, "new_token", return_value="config\n") as new_token,
|
||||
@@ -506,6 +1045,11 @@ class McpServiceConfigTests(unittest.TestCase):
|
||||
self.assertEqual(status, 0)
|
||||
new_token.assert_called_once()
|
||||
self.assertNotIn("enable_ingest", new_token.call_args.kwargs)
|
||||
self.assertIsNone(new_token.call_args.kwargs["host"])
|
||||
self.assertIsNone(new_token.call_args.kwargs["port"])
|
||||
self.assertIsNone(new_token.call_args.kwargs["path"])
|
||||
self.assertIsNone(new_token.call_args.kwargs["public_base_url"])
|
||||
self.assertIsNone(new_token.call_args.kwargs["public_upload_url"])
|
||||
print_fn.assert_called_once_with("config\n", end="")
|
||||
|
||||
def test_main_cleanup_delegates_to_runtime_cleanup(self):
|
||||
|
||||
Reference in New Issue
Block a user