Repair MCP diagnostics and gateway lifecycle
This commit is contained in:
@@ -80,7 +80,7 @@ def test_init_and_auth_repair_normalize_to_existing_handlers(monkeypatch):
|
||||
|
||||
assert calls == [
|
||||
("project", ["init", "--slug", "demo"]),
|
||||
("auth", ["refresh"]),
|
||||
("auth", ["repair"]),
|
||||
]
|
||||
|
||||
|
||||
@@ -121,6 +121,7 @@ def test_doctor_reports_presence_without_exposing_values(tmp_path, monkeypatch):
|
||||
(install_dir / "napseer_mcp_supervisor.py").write_text("# supervisor\n", encoding="utf-8")
|
||||
monkeypatch.setattr(module, "INSTALL_DIR", install_dir)
|
||||
monkeypatch.setattr(module, "runtime_assets_missing", lambda: [])
|
||||
monkeypatch.setattr(module, "mcp_runtime_probe", lambda: {"status": "ok", "transport": True, "read": True, "tool_count": 56})
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"state_dir_status",
|
||||
@@ -150,6 +151,7 @@ def test_doctor_does_not_create_state_directory(tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setattr(module, "INSTALL_DIR", install_dir)
|
||||
monkeypatch.setattr(module, "runtime_assets_missing", lambda: [])
|
||||
monkeypatch.setattr(module, "mcp_runtime_probe", lambda: {"status": "failed", "transport": False, "read": False})
|
||||
|
||||
result = module.doctor_status()
|
||||
|
||||
@@ -229,7 +231,7 @@ def test_bundle_install_stages_validates_and_activates_atomically(tmp_path, monk
|
||||
|
||||
result = module.install_assets()
|
||||
|
||||
assert result["cli_distribution"]["release_version"] == "0.2.0"
|
||||
assert result["cli_distribution"]["release_version"] == "0.2.1"
|
||||
assert (install_dir / "current").is_symlink()
|
||||
assert (install_dir / "current" / "bundle-manifest.json").is_file()
|
||||
assert (install_dir / "current" / "napseer_mcp_server.py").is_file()
|
||||
|
||||
@@ -126,6 +126,19 @@ def test_gateway_schedule_aliases_rejected():
|
||||
expect_unknown(mod, args, "unknown gateway")
|
||||
|
||||
|
||||
def test_gateway_schedule_list_uses_canonical_route():
|
||||
mod = load_module()
|
||||
calls = []
|
||||
mod.cli_gateway_require_unlocked = lambda args, operation: None
|
||||
mod.cli_gateway_service_call = lambda route, args: calls.append((route, dict(args))) or {
|
||||
"status": "listed",
|
||||
"schedules": [],
|
||||
}
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
mod.cli_main(["napseer_mcp_server.py", "gateway", "schedule", "list"])
|
||||
assert calls == [("/local-api/gateway/schedule/list", {})]
|
||||
|
||||
|
||||
def test_gateway_service_run_rejected():
|
||||
"""Lifecycle verbs (run, start, etc.) moved to `nap service`."""
|
||||
mod = load_module()
|
||||
@@ -209,6 +222,7 @@ if __name__ == "__main__":
|
||||
test_gateway_status_aliases_rejected()
|
||||
test_gateway_terminal_aliases_rejected()
|
||||
test_gateway_schedule_aliases_rejected()
|
||||
test_gateway_schedule_list_uses_canonical_route()
|
||||
test_gateway_service_run_rejected()
|
||||
test_gateway_vault_aliases_rejected()
|
||||
test_gateway_rotate_passphrase_aliases_rejected()
|
||||
|
||||
@@ -82,6 +82,7 @@ def run():
|
||||
})
|
||||
mod.REFRESH_TOKEN = "old-oauth-refresh"
|
||||
mod.REFRESH_EXPIRES_AT = "old-refresh-expiry"
|
||||
mod.refresh_public_auth_state = lambda: mod.AUTH
|
||||
mod.request_form_json = lambda method, path, payload, token_required=False: {
|
||||
"access_token": "fresh-oauth-access",
|
||||
"refresh_token": "fresh-oauth-refresh",
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import importlib.util
|
||||
import pathlib
|
||||
import threading
|
||||
import time
|
||||
|
||||
|
||||
def load_module():
|
||||
script_path = pathlib.Path(__file__).resolve().parents[1] / "resources" / "scripts" / "napseer_mcp_server.py"
|
||||
spec = importlib.util.spec_from_file_location("napseer_mcp_server_gateway_auth_test", script_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec and spec.loader
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_renew_auth_serializes_rotating_refresh_tokens():
|
||||
mod = load_module()
|
||||
active = 0
|
||||
maximum = 0
|
||||
guard = threading.Lock()
|
||||
|
||||
def fake_refresh():
|
||||
nonlocal active, maximum
|
||||
with guard:
|
||||
active += 1
|
||||
maximum = max(maximum, active)
|
||||
time.sleep(0.03)
|
||||
with guard:
|
||||
active -= 1
|
||||
return {"status": "renewed"}
|
||||
|
||||
mod.refresh_public_auth_state = lambda: None
|
||||
mod._renew_auth_locked = fake_refresh
|
||||
threads = [threading.Thread(target=mod.renew_auth) for _ in range(3)]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
|
||||
assert maximum == 1
|
||||
|
||||
|
||||
def test_gateway_capability_update_does_not_refresh_by_default():
|
||||
mod = load_module()
|
||||
mod.AUTH = {"worker_capabilities": {}}
|
||||
calls = []
|
||||
mod.save_auth = lambda updates: mod.AUTH.update(updates)
|
||||
mod.renew_auth = lambda: calls.append("renewed")
|
||||
|
||||
result = mod.ensure_gateway_worker_capability(refresh=False)
|
||||
|
||||
assert result["status"] == "updated"
|
||||
assert calls == []
|
||||
@@ -0,0 +1,38 @@
|
||||
import importlib.util
|
||||
import pathlib
|
||||
import types
|
||||
|
||||
|
||||
def load_module():
|
||||
script_path = pathlib.Path(__file__).resolve().parents[1] / "resources" / "scripts" / "napseer_mcp_server.py"
|
||||
spec = importlib.util.spec_from_file_location("napseer_mcp_server_pty_capture_test", script_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec and spec.loader
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_capture_reads_pty_ring_instead_of_tmux():
|
||||
mod = load_module()
|
||||
attach = types.SimpleNamespace(
|
||||
chunks=(
|
||||
types.SimpleNamespace(data=b"first\n"),
|
||||
types.SimpleNamespace(data=b"napseer-lifecycle-ok\n"),
|
||||
),
|
||||
newest_seq=2,
|
||||
replay_gap=False,
|
||||
)
|
||||
manager = types.SimpleNamespace(attach=lambda terminal_id: attach)
|
||||
mod.gateway_terminal_backend_from_id = lambda terminal_id, remote_authenticated=False: (
|
||||
"pty",
|
||||
{"id": terminal_id},
|
||||
)
|
||||
mod.gateway_pty_manager = lambda: manager
|
||||
|
||||
result = mod.gateway_terminal_capture({"terminal_id": "term_test"})
|
||||
|
||||
assert result["status"] == "captured"
|
||||
assert result["terminal_backend"] == "pty"
|
||||
assert result["terminal_id"] == "term_test"
|
||||
assert "napseer-lifecycle-ok" in result["output"]
|
||||
assert result["output_seq"] == 2
|
||||
@@ -5,6 +5,7 @@ import base64
|
||||
import importlib.util
|
||||
import pathlib
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
PROJECT_ID = "11111111-1111-1111-1111-111111111111"
|
||||
@@ -46,6 +47,8 @@ def assert_wrapped_record(mod, record, secret_kind, data_key_epoch=1):
|
||||
|
||||
def run():
|
||||
mod = load_module()
|
||||
test_state = tempfile.TemporaryDirectory()
|
||||
mod.AUTH_PATH = pathlib.Path(test_state.name) / "auth.json"
|
||||
writes = []
|
||||
|
||||
mod.AUTH = {"agent_id": "gateway-agent-1", "account_id": ACCOUNT_ID}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import importlib.util
|
||||
import pathlib
|
||||
|
||||
|
||||
def load_module():
|
||||
script_path = pathlib.Path(__file__).resolve().parents[1] / "resources" / "scripts" / "napseer_mcp_server.py"
|
||||
spec = importlib.util.spec_from_file_location("napseer_mcp_server_worker_repair_test", script_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec and spec.loader
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_gateway_worker_repair_separates_credentials_and_returns_no_tokens():
|
||||
mod = load_module()
|
||||
mod.AUTH = {
|
||||
"account_mode": "operator_project",
|
||||
"project_slug": "demo",
|
||||
"project_name": "Demo",
|
||||
}
|
||||
mod.DEFAULT_PROJECT_ID = "project-1"
|
||||
mod.BASE_URL = "https://api.example.test"
|
||||
mod.GATEWAY_AUTH_PATH = pathlib.Path("/definitely/missing/gateway-auth.json")
|
||||
mod.ensure_container_uuid = lambda: "container-1"
|
||||
mod.normalize_gateway_command = lambda value=None: value or "bash -l"
|
||||
written = []
|
||||
mod.write_gateway_worker_auth = lambda payload: written.append(dict(payload))
|
||||
|
||||
def request(method, path, payload=None, token_required=True, **kwargs):
|
||||
if path == "/v1/service-bootstrap-tokens":
|
||||
return {"bootstrap_token": "bootstrap-secret"}
|
||||
if path == "/v1/service-registrations":
|
||||
assert token_required is False
|
||||
return {
|
||||
"registration": {"id": "registration-1"},
|
||||
"activation_token": "activation-secret",
|
||||
}
|
||||
if path.endswith("/accept"):
|
||||
return {"worker": {"id": "worker-1", "agent_id": "agent-1", "project_id": "project-1"}}
|
||||
if path.endswith("/activate"):
|
||||
assert token_required is False
|
||||
return {
|
||||
"worker": {"id": "worker-1", "agent_id": "agent-1", "project_id": "project-1"},
|
||||
"token": {
|
||||
"access_token": "worker-access-secret",
|
||||
"refresh_token": "worker-refresh-secret",
|
||||
"expires_at": "later",
|
||||
"refresh_expires_at": "much-later",
|
||||
},
|
||||
}
|
||||
raise AssertionError(path)
|
||||
|
||||
mod.request_json = request
|
||||
result = mod.gateway_worker_repair({"display_name": "demo-local-gateway"})
|
||||
|
||||
assert written[0]["account_mode"] == "gateway_worker"
|
||||
assert written[0]["token"] == "worker-access-secret"
|
||||
assert written[0]["refresh_token"] == "worker-refresh-secret"
|
||||
assert "token" not in result
|
||||
assert "refresh_token" not in result
|
||||
assert result["status"] == "repaired"
|
||||
@@ -16,6 +16,11 @@ def load_module():
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec and spec.loader
|
||||
spec.loader.exec_module(module)
|
||||
# Keep project resolution independent from any real .napseer/auth.json in
|
||||
# the checkout. Store the handle on the module so the directory remains
|
||||
# alive for the complete test invocation.
|
||||
module._test_state = tempfile.TemporaryDirectory()
|
||||
module.AUTH_PATH = pathlib.Path(module._test_state.name) / "auth.json"
|
||||
module.AUTH = {"account_id": "acct-1", "token": "token", "token_expires_at": "later"}
|
||||
module.DEFAULT_PROJECT_ID = "project-1"
|
||||
module.project_memory_encryption_active = lambda project_id: False
|
||||
|
||||
@@ -114,6 +114,8 @@ def encrypted_node(mod, full_path="/notes/alpha", content="secret needle", metad
|
||||
|
||||
def run():
|
||||
mod = load_module()
|
||||
test_state = tempfile.TemporaryDirectory()
|
||||
mod.AUTH_PATH = pathlib.Path(test_state.name) / "auth.json"
|
||||
mod.AUTH = {"account_id": ACCOUNT_ID, "token": "token", "token_expires_at": "later"}
|
||||
mod.PROJECT_VAULT_PASSPHRASE = PASSPHRASE
|
||||
mod.DEFAULT_PROJECT_ID = "project-1"
|
||||
@@ -121,6 +123,7 @@ def run():
|
||||
mod.ACTIVE_MEMORY_SECRET_VERSIONS.clear()
|
||||
cache_secret(mod)
|
||||
real_active_memory_secret = mod.active_memory_secret
|
||||
real_get_node_by_id = mod.get_node_by_id
|
||||
|
||||
envelope = mod.encrypt_memory_bytes(
|
||||
"project-1",
|
||||
@@ -247,8 +250,9 @@ def run():
|
||||
mod.ACTIVE_MEMORY_SECRET_VERSIONS.clear()
|
||||
cache_secret(mod)
|
||||
node = encrypted_node(mod, metadata={"private": "secret metadata"})
|
||||
mod.get_node_by_id = real_get_node_by_id
|
||||
mod.request_json = lambda method, path, payload=None, **kwargs: node
|
||||
read = mod.get_node_by_path({"path": "/notes/alpha"})
|
||||
read = mod.get_node_by_id({"node_id": "node-1"})
|
||||
assert read["content_text"] == "secret needle"
|
||||
assert read["metadata"] == {"private": "secret metadata"}
|
||||
|
||||
@@ -257,8 +261,8 @@ def run():
|
||||
moved["folder_path"] = "/renamed"
|
||||
mod.request_json = lambda method, path, payload=None, **kwargs: {"items": [moved]}
|
||||
listed = mod.list_project_nodes({"limit": 10, "include_content": True})
|
||||
assert listed["items"][0]["content_text"] == "secret needle"
|
||||
assert listed["items"][0]["metadata"] == {"private": "secret metadata"}
|
||||
assert "content_text" not in listed["items"][0]
|
||||
assert listed["items"][0]["full_path"] == "/renamed/alpha"
|
||||
|
||||
mod.request_json = lambda method, path, payload=None, **kwargs: {"items": [moved]}
|
||||
compact = mod.list_project_nodes({"limit": 10})
|
||||
@@ -311,7 +315,7 @@ def run():
|
||||
legacy_moved["full_path"] = "/renamed/legacy"
|
||||
mod.request_json = lambda method, path, payload=None, **kwargs: legacy_moved
|
||||
try:
|
||||
mod.get_node_by_path({"path": "/renamed/legacy"})
|
||||
mod.get_node_by_id({"node_id": "node-1"})
|
||||
except RuntimeError:
|
||||
pass
|
||||
else:
|
||||
|
||||
@@ -7,7 +7,7 @@ import sys
|
||||
|
||||
|
||||
def load_module():
|
||||
script_path = pathlib.Path(__file__).resolve().parents[1] / "scripts" / "nap_install.py"
|
||||
script_path = pathlib.Path(__file__).resolve().parents[1] / "resources" / "scripts" / "nap_install.py"
|
||||
spec = importlib.util.spec_from_file_location("nap_install", script_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec and spec.loader
|
||||
@@ -25,10 +25,14 @@ def run():
|
||||
assert calls == []
|
||||
|
||||
assert mod.handle_gateway(["vault", "--help"]) == 0
|
||||
assert calls[-1] == ("gateway", ["vault", "--help"])
|
||||
assert calls == []
|
||||
|
||||
assert mod.handle_gateway(["vault", "list"]) == 0
|
||||
assert calls[-1] == ("gateway", ["vault", "list"])
|
||||
try:
|
||||
mod.handle_gateway(["vault", "list"])
|
||||
except RuntimeError as exc:
|
||||
assert "gateway vault status" in str(exc)
|
||||
else:
|
||||
raise AssertionError("removed gateway vault list alias must not route")
|
||||
|
||||
assert mod.handle_gateway(["vault", "rotate-secret", "--kind", "memory"]) == 0
|
||||
assert calls[-1] == ("gateway", ["vault", "rotate-secret", "--kind", "memory"])
|
||||
@@ -39,18 +43,22 @@ def run():
|
||||
assert "nap gateway vault process" in str(exc), str(exc)
|
||||
else:
|
||||
raise AssertionError("deprecated gateway process shortcut must not route")
|
||||
assert mod.main(["nap", "chat", "secret", "setup"]) == 0
|
||||
assert calls[-1] == ("chat", ["secret", "setup"])
|
||||
try:
|
||||
mod.main(["nap", "chat", "secret", "setup"])
|
||||
except RuntimeError as exc:
|
||||
assert "MCP chat-secret tools" in str(exc)
|
||||
else:
|
||||
raise AssertionError("removed chat compatibility command must not route")
|
||||
try:
|
||||
mod.handle_gateway(["config"])
|
||||
except RuntimeError as exc:
|
||||
assert "unknown gateway subcommand" in str(exc)
|
||||
assert "unknown gateway command" in str(exc)
|
||||
else:
|
||||
raise AssertionError("gateway config alias must not route")
|
||||
try:
|
||||
mod.handle_gateway(["vault-setup", "list"])
|
||||
except RuntimeError as exc:
|
||||
assert "unknown gateway subcommand" in str(exc)
|
||||
assert "unknown gateway command" in str(exc)
|
||||
else:
|
||||
raise AssertionError("deprecated gateway vault-setup alias must not route")
|
||||
|
||||
@@ -75,12 +83,12 @@ def run():
|
||||
assert "unknown" in str(exc), (argv, str(exc))
|
||||
else:
|
||||
raise AssertionError(f"deprecated route must not be accepted at install level: {argv}")
|
||||
# nap project bootstrap is now a deprecated alias handled by the MCP server
|
||||
# (the install side forwards it; the MCP side raises with a clear error message).
|
||||
forwarded = []
|
||||
mod.run_wrapper = lambda command, args: forwarded.append((command, args)) or 0
|
||||
mod.main(["nap", "project", "bootstrap"])
|
||||
assert forwarded == [("project", ["bootstrap"])], forwarded
|
||||
try:
|
||||
mod.main(["nap", "project", "bootstrap"])
|
||||
except RuntimeError as exc:
|
||||
assert "unknown project command" in str(exc)
|
||||
else:
|
||||
raise AssertionError("deprecated project bootstrap alias must not route")
|
||||
|
||||
print("ok: nap installer gateway route smoke passed")
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import importlib.util
|
||||
import pathlib
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
PROJECT_ID = "11111111-1111-1111-1111-111111111111"
|
||||
@@ -21,6 +22,8 @@ def load_module():
|
||||
|
||||
def run():
|
||||
mod = load_module()
|
||||
test_state = tempfile.TemporaryDirectory()
|
||||
mod.AUTH_PATH = pathlib.Path(test_state.name) / "auth.json"
|
||||
saves = []
|
||||
|
||||
def save_auth(updates):
|
||||
@@ -37,6 +40,7 @@ def run():
|
||||
}
|
||||
mod.save_auth = save_auth
|
||||
mod.save_auth_file_credentials = save_auth
|
||||
mod.replace_public_auth_state = lambda updates, clear_keys=(): save_auth(updates)
|
||||
mod.request_json = lambda method, path: {
|
||||
"id": PROJECT_ID,
|
||||
"slug": "existing-project",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import importlib.util
|
||||
import pathlib
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
def load_module():
|
||||
@@ -30,6 +31,8 @@ def assert_raises_hashicorp_closed(fn):
|
||||
|
||||
def run():
|
||||
mod = load_module()
|
||||
test_state = tempfile.TemporaryDirectory()
|
||||
mod.AUTH_PATH = pathlib.Path(test_state.name) / "auth.json"
|
||||
calls = []
|
||||
|
||||
mod.DEFAULT_PROJECT_ID = "project-1"
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Make supported standalone smoke programs part of the pytest gate."""
|
||||
|
||||
import pathlib
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
SMOKES = [
|
||||
"test_anonymous_auth_recovery.py",
|
||||
"test_chat_secret_cli_flow.py",
|
||||
"test_cli_dispatch_consolidation.py",
|
||||
"test_gateway_cloud_routes_removed.py",
|
||||
"test_gateway_configure_consistency.py",
|
||||
"test_gateway_local_auth_refresh.py",
|
||||
"test_gateway_local_vault_key.py",
|
||||
"test_gateway_vault_setup_payload.py",
|
||||
"test_live_auth_state_reload.py",
|
||||
"test_mcp_memory_ergonomics_helpers.py",
|
||||
"test_mcp_memory_node_encryption.py",
|
||||
"test_nap_install_gateway_routes.py",
|
||||
"test_project_attach_oauth.py",
|
||||
"test_project_encryption_enable_payload.py",
|
||||
"test_project_init_wizard.py",
|
||||
"test_two_passphrase_boundary.py",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("filename", SMOKES)
|
||||
def test_standalone_smoke(filename):
|
||||
test_dir = pathlib.Path(__file__).resolve().parent
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(test_dir / filename)],
|
||||
cwd=test_dir.parent,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=90,
|
||||
)
|
||||
assert result.returncode == 0, (
|
||||
f"{filename} failed with exit {result.returncode}\n"
|
||||
f"stdout:\n{result.stdout[-4000:]}\n"
|
||||
f"stderr:\n{result.stderr[-4000:]}"
|
||||
)
|
||||
Reference in New Issue
Block a user