fix: harden MCP and auth runtime lifecycles
This commit is contained in:
@@ -69,6 +69,7 @@ def test_failed_anonymous_refresh_uses_ssh_recovery():
|
||||
mod.DEFAULT_PROJECT_ID = "project"
|
||||
mod.REFRESH_TOKEN = "stale-refresh"
|
||||
mod.REFRESH_EXPIRES_AT = None
|
||||
mod.refresh_public_auth_state = lambda: mod.AUTH
|
||||
mod.auth_public_key = lambda: "ssh-ed25519 test-public-key"
|
||||
mod.sign_with_auth_key = lambda *args: "test-signature"
|
||||
saved = []
|
||||
@@ -106,7 +107,65 @@ def test_failed_anonymous_refresh_uses_ssh_recovery():
|
||||
assert saved[0]["refresh_token"] == "recovered-refresh"
|
||||
|
||||
|
||||
def test_failed_mixed_state_refresh_uses_ssh_recovery():
|
||||
mod = load_module()
|
||||
mod.AUTH = {
|
||||
**anonymous_auth(mod),
|
||||
"account_mode": "operator_project",
|
||||
"oauth_client_id": "nap-cli",
|
||||
"project_id": "project",
|
||||
"ssh_private_key": "test-private-key",
|
||||
"ssh_public_key": "ssh-ed25519 test-public-key",
|
||||
}
|
||||
mod.BASE_URL = mod.AUTH["base_url"]
|
||||
mod.DEFAULT_PROJECT_ID = "project"
|
||||
mod.REFRESH_TOKEN = "stale-refresh"
|
||||
mod.REFRESH_EXPIRES_AT = None
|
||||
mod.refresh_public_auth_state = lambda: mod.AUTH
|
||||
mod.auth_public_key = lambda: "ssh-ed25519 test-public-key"
|
||||
mod.sign_with_auth_key = lambda *args: "test-signature"
|
||||
saved = []
|
||||
mod.save_auth = lambda payload: saved.append(dict(payload))
|
||||
|
||||
def request_form(method, path, payload=None, token_required=False):
|
||||
assert (method, path, token_required) == ("POST", "/v1/oauth/token", False)
|
||||
raise RuntimeError("HTTP 401")
|
||||
|
||||
def request(method, path, payload=None, token_required=False):
|
||||
if path == "/v1/enrollment/refresh":
|
||||
raise RuntimeError("HTTP 401")
|
||||
if path == "/v1/enrollment/challenges":
|
||||
return {"challenge_id": "challenge", "challenge_text": "sign-me"}
|
||||
if path == "/v1/enrollment/verify":
|
||||
return {
|
||||
"token": {
|
||||
"access_token": "recovered-access",
|
||||
"refresh_token": "recovered-refresh",
|
||||
"expires_at": "2099-01-01T00:00:00Z",
|
||||
"refresh_expires_at": "2099-12-31T00:00:00Z",
|
||||
},
|
||||
"worker": {
|
||||
"id": "worker",
|
||||
"agent_id": "agent-id",
|
||||
"name": "agent",
|
||||
"device_fingerprint": "device",
|
||||
"root_path": "/repo",
|
||||
"project_id": "project",
|
||||
},
|
||||
}
|
||||
raise AssertionError(path)
|
||||
|
||||
mod.request_form_json = request_form
|
||||
mod.request_json = request
|
||||
result = mod.renew_auth()
|
||||
|
||||
assert result["method"] == "anonymous_ssh_recovery"
|
||||
assert result["project_id"] == "project"
|
||||
assert saved[0]["refresh_token"] == "recovered-refresh"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_initial_enrollment_persists_refresh_credential()
|
||||
test_failed_anonymous_refresh_uses_ssh_recovery()
|
||||
test_failed_mixed_state_refresh_uses_ssh_recovery()
|
||||
print("ok: anonymous auth recovery tests passed")
|
||||
|
||||
@@ -2,6 +2,8 @@ import importlib.util
|
||||
import hashlib
|
||||
import json
|
||||
import pathlib
|
||||
import time
|
||||
import pytest
|
||||
|
||||
|
||||
SCRIPT_PATH = (
|
||||
@@ -114,6 +116,18 @@ def test_update_does_not_overwrite_repository_source(monkeypatch, capsys):
|
||||
assert json.loads(capsys.readouterr().out)["status"] == "updated"
|
||||
|
||||
|
||||
def test_update_and_expanded_auth_tail_fail_before_side_effects(monkeypatch):
|
||||
module = load_installer()
|
||||
monkeypatch.setattr(module, "install_assets", fail_if_called)
|
||||
monkeypatch.setattr(module, "run_wrapper", fail_if_called)
|
||||
|
||||
with pytest.raises(RuntimeError, match="unexpected argument 'extra' for `nap update`"):
|
||||
module.main(["nap", "update", "extra"])
|
||||
|
||||
with pytest.raises(RuntimeError, match="Bash expands `!nap`"):
|
||||
module.main(["nap", "auth", "login", "project", "attach", "update"])
|
||||
|
||||
|
||||
def test_doctor_reports_presence_without_exposing_values(tmp_path, monkeypatch):
|
||||
module = load_installer()
|
||||
state_dir = tmp_path / ".napseer"
|
||||
@@ -136,7 +150,18 @@ 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,
|
||||
"mcp_runtime_probe",
|
||||
lambda: {
|
||||
"status": "local_ready",
|
||||
"transport": True,
|
||||
"contract": True,
|
||||
"local_state": True,
|
||||
"read": True,
|
||||
"tool_count": 56,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"state_dir_status",
|
||||
@@ -174,6 +199,153 @@ def test_doctor_does_not_create_state_directory(tmp_path, monkeypatch):
|
||||
assert not state_dir.exists()
|
||||
|
||||
|
||||
def test_doctor_treats_missing_project_as_setup_not_runtime_repair(tmp_path, monkeypatch):
|
||||
module = load_installer()
|
||||
install_dir = tmp_path / "install"
|
||||
install_dir.mkdir()
|
||||
(install_dir / "napseer_mcp_server.py").write_text("# worker\n", encoding="utf-8")
|
||||
(install_dir / "napseer_mcp_supervisor.py").write_text("# supervisor\n", encoding="utf-8")
|
||||
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": "local_ready",
|
||||
"transport": True,
|
||||
"contract": True,
|
||||
"local_state": True,
|
||||
"authentication": {"configured": False, "verified": False},
|
||||
"project": {"configured": False, "verified": False},
|
||||
},
|
||||
)
|
||||
|
||||
result = module.doctor_status()
|
||||
|
||||
assert result["status"] == "setup_available"
|
||||
assert result["issues"] == []
|
||||
assert result["setup"][0]["code"] == "project_not_initialized"
|
||||
assert result["lifecycle"]["transport"] == "ready"
|
||||
assert not (tmp_path / ".napseer").exists()
|
||||
|
||||
|
||||
def test_mcp_status_separates_runtime_health_from_project_readiness(tmp_path, monkeypatch):
|
||||
module = load_installer()
|
||||
install_dir = tmp_path / "install"
|
||||
install_dir.mkdir()
|
||||
(install_dir / "napseer_mcp_server.py").write_text("# worker\n", encoding="utf-8")
|
||||
(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": "local_ready",
|
||||
"transport": True,
|
||||
"contract": True,
|
||||
"local_state": True,
|
||||
"tool_count": 56,
|
||||
"authentication": {"configured": False, "verified": False},
|
||||
"project": {"configured": False, "verified": False},
|
||||
},
|
||||
)
|
||||
|
||||
result = module.mcp_status()
|
||||
|
||||
assert result["status"] == "probe_ok"
|
||||
assert result["lifecycle"]["runtime"] == "installed"
|
||||
assert result["lifecycle"]["transport"] == "ready"
|
||||
assert result["lifecycle"]["authentication"] == "unconfigured"
|
||||
assert result["lifecycle"]["project"] == "unconfigured"
|
||||
assert "not tested" in result["message"]
|
||||
|
||||
|
||||
def write_probe_server(path, trace_path, delay=0):
|
||||
path.write_text(
|
||||
f"""\
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
import time
|
||||
|
||||
TRACE = pathlib.Path({str(trace_path)!r})
|
||||
DELAY = {delay!r}
|
||||
|
||||
for line in sys.stdin:
|
||||
message = json.loads(line)
|
||||
params = message.get("params") or {{}}
|
||||
record = {{"method": message.get("method")}}
|
||||
if message.get("method") == "initialize":
|
||||
record["protocol_version"] = params.get("protocolVersion")
|
||||
if message.get("method") == "tools/call":
|
||||
record["tool"] = params.get("name")
|
||||
with TRACE.open("a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(record) + "\\n")
|
||||
if "id" not in message:
|
||||
continue
|
||||
time.sleep(DELAY)
|
||||
if message.get("method") == "initialize":
|
||||
result = {{"protocolVersion": "2025-11-25", "capabilities": {{}}, "serverInfo": {{"name": "fake", "version": "1"}}}}
|
||||
elif message.get("method") == "tools/list":
|
||||
result = {{"tools": [{{"name": "nap_whoami"}}]}}
|
||||
else:
|
||||
payload = {{"project_configured": False, "has_token": False}}
|
||||
result = {{"structuredContent": payload, "content": [{{"type": "text", "text": json.dumps(payload)}}], "isError": False}}
|
||||
print(json.dumps({{"jsonrpc": "2.0", "id": message["id"], "result": result}}), flush=True)
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def test_mcp_runtime_probe_uses_local_status_without_project_bootstrap(tmp_path, monkeypatch):
|
||||
module = load_installer()
|
||||
supervisor = tmp_path / "probe.py"
|
||||
worker = tmp_path / "worker.py"
|
||||
trace = tmp_path / "trace.jsonl"
|
||||
write_probe_server(supervisor, trace)
|
||||
worker.write_text("# exists\n", encoding="utf-8")
|
||||
paths = {
|
||||
"napseer_mcp_supervisor.py": supervisor,
|
||||
"napseer_mcp_server.py": worker,
|
||||
}
|
||||
monkeypatch.setattr(module, "runtime_asset_path", lambda name: paths[name])
|
||||
|
||||
result = module.mcp_runtime_probe(timeout_seconds=2)
|
||||
records = [json.loads(line) for line in trace.read_text(encoding="utf-8").splitlines()]
|
||||
|
||||
assert result["status"] == "local_ready"
|
||||
assert result["transport"] is True
|
||||
assert result["local_state"] is True
|
||||
assert records[0]["protocol_version"] == "2025-11-25"
|
||||
assert [record.get("tool") for record in records if record.get("tool")] == ["nap_whoami"]
|
||||
assert any(record["method"] == "notifications/initialized" for record in records)
|
||||
|
||||
|
||||
def test_mcp_runtime_probe_timeout_is_one_total_deadline(tmp_path, monkeypatch):
|
||||
module = load_installer()
|
||||
supervisor = tmp_path / "slow-probe.py"
|
||||
worker = tmp_path / "worker.py"
|
||||
trace = tmp_path / "trace.jsonl"
|
||||
write_probe_server(supervisor, trace, delay=0.15)
|
||||
worker.write_text("# exists\n", encoding="utf-8")
|
||||
paths = {
|
||||
"napseer_mcp_supervisor.py": supervisor,
|
||||
"napseer_mcp_server.py": worker,
|
||||
}
|
||||
monkeypatch.setattr(module, "runtime_asset_path", lambda name: paths[name])
|
||||
started = time.monotonic()
|
||||
|
||||
result = module.mcp_runtime_probe(timeout_seconds=0.2)
|
||||
|
||||
assert result["status"] == "failed"
|
||||
assert result["error_class"] == "TimeoutError"
|
||||
assert result["transport"] is True
|
||||
assert result["contract"] is False
|
||||
assert time.monotonic() - started < 0.35
|
||||
|
||||
|
||||
def test_wrapper_dispatch_never_installs_missing_runtime(monkeypatch):
|
||||
module = load_installer()
|
||||
monkeypatch.setattr(module, "runtime_assets_missing", lambda: ["napseer_mcp_server.py"])
|
||||
|
||||
@@ -36,6 +36,18 @@ def expect_unknown(mod, argv, expected_fragment):
|
||||
raise AssertionError(f"expected RuntimeError for {argv}")
|
||||
|
||||
|
||||
def test_auth_login_rejects_history_expansion_tail_before_browser():
|
||||
mod = load_module()
|
||||
mod.operator_account_login = lambda _args: (_ for _ in ()).throw(
|
||||
AssertionError("malformed auth login must fail before browser login")
|
||||
)
|
||||
expect_unknown(
|
||||
mod,
|
||||
["napseer_mcp_server.py", "auth", "login", "project", "attach", "update"],
|
||||
"Bash expands `!nap`",
|
||||
)
|
||||
|
||||
|
||||
def test_chat_secret_aliases_rejected():
|
||||
mod = load_module()
|
||||
for args in [
|
||||
@@ -214,6 +226,7 @@ def test_project_encryption_status_aliases_are_read_only_compatibility():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_auth_login_rejects_history_expansion_tail_before_browser()
|
||||
test_chat_secret_aliases_rejected()
|
||||
test_lineage_aliases_rejected()
|
||||
test_plan_aliases_rejected()
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regression tests for bounded DNS address fallback in the local wrapper."""
|
||||
|
||||
import importlib.util
|
||||
import pathlib
|
||||
import socket
|
||||
import sys
|
||||
|
||||
|
||||
def load_module():
|
||||
script_path = (
|
||||
pathlib.Path(__file__).resolve().parents[1]
|
||||
/ "resources"
|
||||
/ "scripts"
|
||||
/ "napseer_mcp_server.py"
|
||||
)
|
||||
sys.path.insert(0, str(script_path.parent))
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"napseer_mcp_server_address_fallback_test", script_path
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec and spec.loader
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class FakeSocket:
|
||||
def __init__(self, family, attempts, failing_addresses):
|
||||
self.family = family
|
||||
self.attempts = attempts
|
||||
self.failing_addresses = failing_addresses
|
||||
self.timeouts = []
|
||||
self.closed = False
|
||||
|
||||
def settimeout(self, value):
|
||||
self.timeouts.append(value)
|
||||
|
||||
def bind(self, _address):
|
||||
return None
|
||||
|
||||
def connect(self, address):
|
||||
self.attempts.append((self.family, address[0]))
|
||||
if address[0] in self.failing_addresses:
|
||||
raise TimeoutError("unreachable test address")
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
def test_ipv4_is_preferred_and_failed_addresses_are_bounded():
|
||||
mod = load_module()
|
||||
attempts = []
|
||||
sockets = []
|
||||
|
||||
def resolver(_host, port, _family, _socktype):
|
||||
return [
|
||||
(socket.AF_INET6, socket.SOCK_STREAM, 6, "", ("2001:db8::1", port, 0, 0)),
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("192.0.2.1", port)),
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("192.0.2.2", port)),
|
||||
]
|
||||
|
||||
def factory(family, _socktype, _proto):
|
||||
created = FakeSocket(family, attempts, {"192.0.2.1"})
|
||||
sockets.append(created)
|
||||
return created
|
||||
|
||||
connected = mod.connect_with_address_fallback(
|
||||
("api.example.test", 443),
|
||||
timeout=30,
|
||||
resolver=resolver,
|
||||
socket_factory=factory,
|
||||
)
|
||||
|
||||
assert connected is sockets[1]
|
||||
assert attempts == [
|
||||
(socket.AF_INET, "192.0.2.1"),
|
||||
(socket.AF_INET, "192.0.2.2"),
|
||||
]
|
||||
assert sockets[0].timeouts == [mod.API_CONNECT_ATTEMPT_TIMEOUT_SECONDS]
|
||||
assert sockets[0].closed
|
||||
assert sockets[1].timeouts == [mod.API_CONNECT_ATTEMPT_TIMEOUT_SECONDS, 30.0]
|
||||
|
||||
|
||||
def test_ipv4_only_never_attempts_ipv6():
|
||||
mod = load_module()
|
||||
attempts = []
|
||||
resolver_families = []
|
||||
|
||||
def resolver(_host, port, family, _socktype):
|
||||
resolver_families.append(family)
|
||||
return [
|
||||
(socket.AF_INET6, socket.SOCK_STREAM, 6, "", ("2001:db8::1", port, 0, 0)),
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("192.0.2.1", port)),
|
||||
]
|
||||
|
||||
def factory(family, _socktype, _proto):
|
||||
return FakeSocket(family, attempts, {"192.0.2.1"})
|
||||
|
||||
try:
|
||||
mod.connect_with_address_fallback(
|
||||
("api.example.test", 443),
|
||||
timeout=30,
|
||||
resolver=resolver,
|
||||
socket_factory=factory,
|
||||
ipv4_only=True,
|
||||
)
|
||||
except TimeoutError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("IPv4-only connection unexpectedly succeeded")
|
||||
|
||||
assert resolver_families == [socket.AF_INET]
|
||||
assert attempts == [(socket.AF_INET, "192.0.2.1")]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_ipv4_is_preferred_and_failed_addresses_are_bounded()
|
||||
test_ipv4_only_never_attempts_ipv6()
|
||||
print("ok: connection address fallback tests passed")
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regression tests for bounded DNS address fallback in the updater transport."""
|
||||
|
||||
import importlib.util
|
||||
import pathlib
|
||||
import socket
|
||||
|
||||
|
||||
def load_module():
|
||||
script_path = (
|
||||
pathlib.Path(__file__).resolve().parents[1]
|
||||
/ "resources"
|
||||
/ "scripts"
|
||||
/ "nap_install.py"
|
||||
)
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"nap_install_address_fallback_test", script_path
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec and spec.loader
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class FakeSocket:
|
||||
def __init__(self, family, attempts, failing_addresses):
|
||||
self.family = family
|
||||
self.attempts = attempts
|
||||
self.failing_addresses = failing_addresses
|
||||
self.timeouts = []
|
||||
self.closed = False
|
||||
|
||||
def settimeout(self, value):
|
||||
self.timeouts.append(value)
|
||||
|
||||
def bind(self, _address):
|
||||
return None
|
||||
|
||||
def connect(self, address):
|
||||
self.attempts.append((self.family, address[0]))
|
||||
if address[0] in self.failing_addresses:
|
||||
raise TimeoutError("unreachable test address")
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
def test_updater_prefers_ipv4_and_bounds_failed_addresses():
|
||||
mod = load_module()
|
||||
attempts = []
|
||||
sockets = []
|
||||
|
||||
def resolver(_host, port, _family, _socktype):
|
||||
return [
|
||||
(socket.AF_INET6, socket.SOCK_STREAM, 6, "", ("2001:db8::1", port, 0, 0)),
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("192.0.2.1", port)),
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("192.0.2.2", port)),
|
||||
]
|
||||
|
||||
def factory(family, _socktype, _proto):
|
||||
created = FakeSocket(family, attempts, {"192.0.2.1"})
|
||||
sockets.append(created)
|
||||
return created
|
||||
|
||||
connected = mod.connect_with_address_fallback(
|
||||
("api.example.test", 443),
|
||||
timeout=30,
|
||||
resolver=resolver,
|
||||
socket_factory=factory,
|
||||
)
|
||||
|
||||
assert connected is sockets[1]
|
||||
assert attempts == [
|
||||
(socket.AF_INET, "192.0.2.1"),
|
||||
(socket.AF_INET, "192.0.2.2"),
|
||||
]
|
||||
assert sockets[0].timeouts == [mod.API_CONNECT_ATTEMPT_TIMEOUT_SECONDS]
|
||||
assert sockets[0].closed
|
||||
assert sockets[1].timeouts == [mod.API_CONNECT_ATTEMPT_TIMEOUT_SECONDS, 30.0]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_updater_prefers_ipv4_and_bounds_failed_addresses()
|
||||
print("ok: updater connection address fallback tests passed")
|
||||
@@ -55,13 +55,44 @@ def run():
|
||||
|
||||
current = {}
|
||||
mod.DEFAULT_PROJECT_ID = "stale-project"
|
||||
bootstrap_calls = []
|
||||
|
||||
def bootstrap_project(args):
|
||||
bootstrap_calls.append(dict(args))
|
||||
mod.DEFAULT_PROJECT_ID = "anonymous-project"
|
||||
return {"status": "created", "project_id": "anonymous-project"}
|
||||
|
||||
mod.bootstrap_project = bootstrap_project
|
||||
assert mod.resolve_project_id({}) == "anonymous-project"
|
||||
assert bootstrap_calls == [
|
||||
{
|
||||
"slug": mod.default_project_slug(),
|
||||
"name": mod.default_project_name(mod.default_project_slug()),
|
||||
"description": "Created automatically by the local Napseer MCP runtime.",
|
||||
"encryption": "standard",
|
||||
}
|
||||
]
|
||||
|
||||
current = {}
|
||||
mod.DEFAULT_PROJECT_ID = None
|
||||
|
||||
def limited_project(_args):
|
||||
raise mod.SafeToolError(
|
||||
"anonymous_space_limit_reached",
|
||||
"Anonymous space limit reached. Run `nap project claim` to authenticate and continue.",
|
||||
status=403,
|
||||
service_code="anonymous_space_limit_reached",
|
||||
)
|
||||
|
||||
mod.bootstrap_project = limited_project
|
||||
try:
|
||||
mod.resolve_project_id({})
|
||||
except mod.SafeToolError as exc:
|
||||
assert exc.code == "project_not_configured"
|
||||
assert "nap project attach" in exc.safe_message
|
||||
assert exc.code == "anonymous_space_limit_reached"
|
||||
assert exc.service_code == "anonymous_space_limit_reached"
|
||||
assert "nap project claim" in exc.safe_message
|
||||
else:
|
||||
raise AssertionError("missing project selection must return an actionable safe error")
|
||||
raise AssertionError("anonymous space limit must remain actionable")
|
||||
|
||||
def open_success(request, timeout=None):
|
||||
requests.append(request)
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import importlib.util
|
||||
import pathlib
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
def load_module():
|
||||
script_path = (
|
||||
pathlib.Path(__file__).resolve().parents[1]
|
||||
/ "resources"
|
||||
/ "scripts"
|
||||
/ "napseer_mcp_server.py"
|
||||
)
|
||||
sys.path.insert(0, str(script_path.parent))
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"napseer_mcp_server_search_latency_test", script_path
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec and spec.loader
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_natural_language_discovery_uses_one_backend_search_request(tmp_path):
|
||||
mod = load_module()
|
||||
calls = []
|
||||
mod.INDEX_PATH = tmp_path / "missing-index.sqlite"
|
||||
mod.resolve_project_id = lambda _args: "project-1"
|
||||
mod.project_encryption_state = lambda _project_id: "standard"
|
||||
|
||||
def request(method, path, payload=None, **_kwargs):
|
||||
calls.append((method, path, payload))
|
||||
return {"items": [], "next_cursor": None, "query_analysis": {"mode": "terms"}}
|
||||
|
||||
mod.request_json = request
|
||||
|
||||
result = mod.discover_memory(
|
||||
{
|
||||
"q": "SIARA signer facturacion certificate API XML signature",
|
||||
"limit": 20,
|
||||
"view": "summary",
|
||||
}
|
||||
)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert len(calls) == 1
|
||||
assert calls[0][0] == "GET"
|
||||
assert "/nodes?" in calls[0][1]
|
||||
|
||||
|
||||
def test_sparse_index_search_latency_does_not_scale_with_query_variants(tmp_path):
|
||||
mod = load_module()
|
||||
calls = []
|
||||
mod.INDEX_PATH = tmp_path / "missing-index.sqlite"
|
||||
mod.resolve_project_id = lambda _args: "project-1"
|
||||
mod.project_encryption_state = lambda _project_id: "standard"
|
||||
|
||||
def request(_method, _path, _payload=None, **_kwargs):
|
||||
calls.append(time.monotonic())
|
||||
time.sleep(0.03)
|
||||
return {"items": [], "next_cursor": None}
|
||||
|
||||
mod.request_json = request
|
||||
started = time.monotonic()
|
||||
|
||||
mod.search_memory(
|
||||
{
|
||||
"q": "one two three four five six seven eight",
|
||||
"limit": 20,
|
||||
"view": "summary",
|
||||
}
|
||||
)
|
||||
|
||||
elapsed = time.monotonic() - started
|
||||
assert len(calls) == 1
|
||||
assert elapsed < 0.15
|
||||
+182
-11
@@ -10,6 +10,7 @@ import time
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
SUPERVISOR = ROOT / "resources" / "scripts" / "napseer_mcp_supervisor.py"
|
||||
REAL_WORKER = ROOT / "resources" / "scripts" / "napseer_mcp_server.py"
|
||||
|
||||
|
||||
def write_worker(path: pathlib.Path, version: int) -> None:
|
||||
@@ -18,31 +19,64 @@ def write_worker(path: pathlib.Path, version: int) -> None:
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
VERSION = {version}
|
||||
WRITE_LOCK = threading.Lock()
|
||||
CANCELLED = set()
|
||||
|
||||
for line in sys.stdin:
|
||||
message = json.loads(line)
|
||||
if message.get("method") == "notifications/initialized":
|
||||
continue
|
||||
if message.get("method") == "tools/call":
|
||||
|
||||
def emit(payload):
|
||||
with WRITE_LOCK:
|
||||
print(json.dumps(payload), flush=True)
|
||||
|
||||
|
||||
def handle(message):
|
||||
method = message.get("method")
|
||||
if method == "notifications/initialized":
|
||||
return
|
||||
if method == "notifications/cancelled":
|
||||
CANCELLED.add((message.get("params") or {{}}).get("requestId"))
|
||||
return
|
||||
if method == "notifications/noisy":
|
||||
emit({{"jsonrpc": "2.0", "id": None, "error": {{"code": -32601, "message": "noise"}}}})
|
||||
return
|
||||
if "id" not in message:
|
||||
return
|
||||
if method == "tools/call":
|
||||
name = (message.get("params") or {{}}).get("name")
|
||||
if name == "slow":
|
||||
time.sleep(1)
|
||||
if name == "crash":
|
||||
time.sleep(0.1)
|
||||
os._exit(17)
|
||||
result = {{"pid": os.getpid(), "version": VERSION, "name": name}}
|
||||
else:
|
||||
result = {{"version": VERSION}}
|
||||
print(json.dumps({{"jsonrpc": "2.0", "id": message.get("id"), "result": result}}), flush=True)
|
||||
if message.get("id") in CANCELLED:
|
||||
return
|
||||
emit({{"jsonrpc": "2.0", "id": message.get("id"), "result": result}})
|
||||
|
||||
for line in sys.stdin:
|
||||
message = json.loads(line)
|
||||
if "id" not in message:
|
||||
handle(message)
|
||||
continue
|
||||
threading.Thread(target=handle, args=(message,), daemon=True).start()
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
class SupervisorClient:
|
||||
def __init__(self, worker_path: pathlib.Path):
|
||||
def __init__(self, worker_path: pathlib.Path, project_root: pathlib.Path | None = None):
|
||||
environment = os.environ.copy()
|
||||
environment["NAPSEER_MCP_WORKER_PATH"] = str(worker_path)
|
||||
environment["NAPSEER_MCP_RESPONSE_TIMEOUT_SECONDS"] = "5"
|
||||
environment["NAPSEER_TELEMETRY"] = "0"
|
||||
if project_root is not None:
|
||||
environment["NAPSEER_PROJECT_ROOT"] = str(project_root)
|
||||
self.process = subprocess.Popen(
|
||||
[sys.executable, str(SUPERVISOR)],
|
||||
stdin=subprocess.PIPE,
|
||||
@@ -52,21 +86,40 @@ class SupervisorClient:
|
||||
)
|
||||
|
||||
def request(self, request_id: int, method: str, params=None):
|
||||
self.send(request_id, method, params)
|
||||
return self.receive()
|
||||
|
||||
def send(self, request_id: int, method: str, params=None):
|
||||
assert self.process.stdin is not None
|
||||
assert self.process.stdout is not None
|
||||
message = {"jsonrpc": "2.0", "id": request_id, "method": method}
|
||||
if params is not None:
|
||||
message["params"] = params
|
||||
self.process.stdin.write(json.dumps(message).encode("utf-8") + b"\n")
|
||||
self.process.stdin.flush()
|
||||
readable, _, _ = select.select([self.process.stdout], [], [], 8)
|
||||
|
||||
def notify(self, method: str, params=None):
|
||||
assert self.process.stdin is not None
|
||||
message = {"jsonrpc": "2.0", "method": method}
|
||||
if params is not None:
|
||||
message["params"] = params
|
||||
self.process.stdin.write(json.dumps(message).encode("utf-8") + b"\n")
|
||||
self.process.stdin.flush()
|
||||
|
||||
def receive(self, timeout=8):
|
||||
assert self.process.stdout is not None
|
||||
readable, _, _ = select.select([self.process.stdout], [], [], timeout)
|
||||
assert readable, "supervisor did not return a response"
|
||||
return json.loads(self.process.stdout.readline())
|
||||
|
||||
def close(self):
|
||||
if self.process.poll() is None:
|
||||
self.process.terminate()
|
||||
self.process.wait(timeout=5)
|
||||
assert self.process.stdin is not None
|
||||
self.process.stdin.close()
|
||||
try:
|
||||
self.process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.process.terminate()
|
||||
self.process.wait(timeout=5)
|
||||
|
||||
|
||||
def test_restarts_worker_killed_between_requests(tmp_path):
|
||||
@@ -86,6 +139,124 @@ def test_restarts_worker_killed_between_requests(tmp_path):
|
||||
client.close()
|
||||
|
||||
|
||||
def test_concurrent_requests_are_correlated_without_head_of_line_blocking(tmp_path):
|
||||
worker_path = tmp_path / "worker.py"
|
||||
write_worker(worker_path, 1)
|
||||
client = SupervisorClient(worker_path)
|
||||
try:
|
||||
client.send(1, "tools/call", {"name": "slow", "arguments": {}})
|
||||
time.sleep(0.05)
|
||||
client.send(2, "tools/call", {"name": "fast", "arguments": {}})
|
||||
|
||||
first = client.receive(timeout=0.75)
|
||||
second = client.receive(timeout=2)
|
||||
|
||||
assert first["id"] == 2
|
||||
assert first["result"]["name"] == "fast"
|
||||
assert second["id"] == 1
|
||||
assert second["result"]["name"] == "slow"
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def test_notification_response_cannot_poison_the_next_request(tmp_path):
|
||||
worker_path = tmp_path / "worker.py"
|
||||
write_worker(worker_path, 1)
|
||||
client = SupervisorClient(worker_path)
|
||||
try:
|
||||
client.notify("notifications/noisy")
|
||||
time.sleep(0.05)
|
||||
|
||||
response = client.request(7, "tools/list")
|
||||
|
||||
assert response["id"] == 7
|
||||
assert response["result"]["version"] == 1
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def test_cancellation_drops_the_cancelled_and_late_worker_response(tmp_path):
|
||||
worker_path = tmp_path / "worker.py"
|
||||
write_worker(worker_path, 1)
|
||||
client = SupervisorClient(worker_path)
|
||||
try:
|
||||
client.send(1, "tools/call", {"name": "slow", "arguments": {}})
|
||||
time.sleep(0.05)
|
||||
client.notify(
|
||||
"notifications/cancelled",
|
||||
{"requestId": 1, "reason": "test cancellation"},
|
||||
)
|
||||
client.send(2, "tools/call", {"name": "fast", "arguments": {}})
|
||||
|
||||
response = client.receive(timeout=0.75)
|
||||
assert response["id"] == 2
|
||||
|
||||
time.sleep(1.05)
|
||||
follow_up = client.request(3, "tools/list")
|
||||
assert follow_up["id"] == 3
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def test_worker_crash_fails_every_in_flight_request_once_then_recovers(tmp_path):
|
||||
worker_path = tmp_path / "worker.py"
|
||||
write_worker(worker_path, 1)
|
||||
client = SupervisorClient(worker_path)
|
||||
try:
|
||||
client.send(1, "tools/call", {"name": "slow", "arguments": {}})
|
||||
client.send(2, "tools/call", {"name": "crash", "arguments": {}})
|
||||
|
||||
failed = [client.receive(timeout=2), client.receive(timeout=2)]
|
||||
|
||||
assert {item["id"] for item in failed} == {1, 2}
|
||||
assert all(item["error"]["code"] == -32098 for item in failed)
|
||||
|
||||
recovered = client.request(3, "tools/list")
|
||||
assert recovered["id"] == 3
|
||||
assert recovered["result"]["version"] == 1
|
||||
assert client.process.poll() is None
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def test_real_supervisor_worker_protocol_lifecycle(tmp_path):
|
||||
client = SupervisorClient(REAL_WORKER, project_root=tmp_path)
|
||||
try:
|
||||
initialized = client.request(
|
||||
1,
|
||||
"initialize",
|
||||
{
|
||||
"protocolVersion": "2025-11-25",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "protocol-test", "version": "1"},
|
||||
},
|
||||
)
|
||||
client.notify("notifications/initialized", {})
|
||||
client.send(2, "tools/list")
|
||||
client.send(3, "tools/call", {"name": "nap_whoami", "arguments": {}})
|
||||
|
||||
responses = {item["id"]: item for item in (client.receive(), client.receive())}
|
||||
|
||||
assert initialized["result"]["protocolVersion"] == "2025-11-25"
|
||||
tool_names = {tool["name"] for tool in responses[2]["result"]["tools"]}
|
||||
assert "nap_whoami" in tool_names
|
||||
assert responses[3]["result"]["isError"] is False
|
||||
|
||||
client.notify("notifications/unknown", {"proof": "one-way"})
|
||||
follow_up = client.request(
|
||||
4,
|
||||
"tools/call",
|
||||
{"name": "nap_whoami", "arguments": {}},
|
||||
)
|
||||
assert follow_up["id"] == 4
|
||||
assert follow_up["result"]["isError"] is False
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
assert client.process.returncode == 0
|
||||
assert not (tmp_path / ".napseer").exists()
|
||||
|
||||
|
||||
def test_reloads_replaced_worker_between_requests(tmp_path):
|
||||
worker_path = tmp_path / "worker.py"
|
||||
write_worker(worker_path, 1)
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import importlib.util
|
||||
import pathlib
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
|
||||
def load_module():
|
||||
script_path = (
|
||||
pathlib.Path(__file__).resolve().parents[1]
|
||||
/ "resources"
|
||||
/ "scripts"
|
||||
/ "napseer_mcp_server.py"
|
||||
)
|
||||
sys.path.insert(0, str(script_path.parent))
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"napseer_mcp_server_lifecycle_test", script_path
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec and spec.loader
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def wait_until(predicate, timeout=2):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(0.01)
|
||||
raise AssertionError("condition was not reached before timeout")
|
||||
|
||||
|
||||
def test_worker_runtime_completes_fast_read_before_slow_read():
|
||||
mod = load_module()
|
||||
emitted = []
|
||||
emitted_lock = threading.Lock()
|
||||
|
||||
def handler(message):
|
||||
if message["id"] == 1:
|
||||
time.sleep(0.4)
|
||||
return mod.rpc_result(message["id"], {"done": True})
|
||||
|
||||
def emit(response):
|
||||
with emitted_lock:
|
||||
emitted.append(response)
|
||||
|
||||
runtime = mod.McpRequestRuntime(handler=handler, sender=emit, max_workers=4)
|
||||
try:
|
||||
runtime.dispatch({"jsonrpc": "2.0", "id": 1, "method": "tools/call"})
|
||||
runtime.dispatch({"jsonrpc": "2.0", "id": 2, "method": "tools/call"})
|
||||
|
||||
wait_until(lambda: len(emitted) == 2)
|
||||
|
||||
assert [response["id"] for response in emitted] == [2, 1]
|
||||
finally:
|
||||
runtime.close()
|
||||
|
||||
|
||||
def test_worker_runtime_cancellation_suppresses_unused_result():
|
||||
mod = load_module()
|
||||
emitted = []
|
||||
|
||||
def handler(message):
|
||||
if message["id"] == 1:
|
||||
time.sleep(0.3)
|
||||
return mod.rpc_result(message["id"], {"done": True})
|
||||
|
||||
runtime = mod.McpRequestRuntime(handler=handler, sender=emitted.append, max_workers=2)
|
||||
try:
|
||||
runtime.dispatch({"jsonrpc": "2.0", "id": 1, "method": "tools/call"})
|
||||
runtime.dispatch(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "notifications/cancelled",
|
||||
"params": {"requestId": 1, "reason": "unused"},
|
||||
}
|
||||
)
|
||||
runtime.dispatch({"jsonrpc": "2.0", "id": 2, "method": "tools/call"})
|
||||
|
||||
wait_until(lambda: any(response["id"] == 2 for response in emitted))
|
||||
time.sleep(0.35)
|
||||
|
||||
assert [response["id"] for response in emitted] == [2]
|
||||
finally:
|
||||
runtime.close()
|
||||
|
||||
|
||||
def test_initialize_cannot_be_cancelled():
|
||||
mod = load_module()
|
||||
emitted = []
|
||||
|
||||
def handler(message):
|
||||
time.sleep(0.1)
|
||||
return mod.rpc_result(message["id"], {"initialized": True})
|
||||
|
||||
runtime = mod.McpRequestRuntime(handler=handler, sender=emitted.append, max_workers=2)
|
||||
try:
|
||||
runtime.dispatch({"jsonrpc": "2.0", "id": 1, "method": "initialize"})
|
||||
runtime.dispatch(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "notifications/cancelled",
|
||||
"params": {"requestId": 1},
|
||||
}
|
||||
)
|
||||
|
||||
wait_until(lambda: len(emitted) == 1)
|
||||
|
||||
assert emitted[0]["id"] == 1
|
||||
finally:
|
||||
runtime.close()
|
||||
|
||||
|
||||
def test_unknown_notification_never_returns_a_response():
|
||||
mod = load_module()
|
||||
|
||||
response = mod.handle(
|
||||
{"jsonrpc": "2.0", "method": "notifications/unknown", "params": {}}
|
||||
)
|
||||
|
||||
assert response is None
|
||||
|
||||
|
||||
def test_mutating_tools_are_serialized_while_reads_can_overlap(monkeypatch):
|
||||
mod = load_module()
|
||||
active = 0
|
||||
max_active = 0
|
||||
state_lock = threading.Lock()
|
||||
|
||||
def fake_call(_name, _args):
|
||||
nonlocal active, max_active
|
||||
with state_lock:
|
||||
active += 1
|
||||
max_active = max(max_active, active)
|
||||
time.sleep(0.1)
|
||||
with state_lock:
|
||||
active -= 1
|
||||
return {"ok": True}
|
||||
|
||||
monkeypatch.setattr(mod, "call_tool_impl", fake_call)
|
||||
|
||||
read_threads = [
|
||||
threading.Thread(target=mod.call_tool, args=("nap_whoami", {}))
|
||||
for _ in range(2)
|
||||
]
|
||||
for thread in read_threads:
|
||||
thread.start()
|
||||
for thread in read_threads:
|
||||
thread.join()
|
||||
assert max_active == 2
|
||||
|
||||
max_active = 0
|
||||
write_threads = [
|
||||
threading.Thread(target=mod.call_tool, args=("nap_create_node", {}))
|
||||
for _ in range(2)
|
||||
]
|
||||
for thread in write_threads:
|
||||
thread.start()
|
||||
for thread in write_threads:
|
||||
thread.join()
|
||||
assert max_active == 1
|
||||
|
||||
|
||||
def test_concurrent_first_reads_share_one_project_bootstrap(tmp_path, monkeypatch):
|
||||
mod = load_module()
|
||||
mod.AUTH_PATH = tmp_path / "missing-auth.json"
|
||||
mod.DEFAULT_PROJECT_ID = None
|
||||
monkeypatch.setattr(mod, "refresh_public_auth_state", lambda: None)
|
||||
calls = []
|
||||
|
||||
def bootstrap(_args):
|
||||
calls.append("bootstrap")
|
||||
time.sleep(0.1)
|
||||
mod.DEFAULT_PROJECT_ID = "project-1"
|
||||
return {"project_id": "project-1"}
|
||||
|
||||
monkeypatch.setattr(mod, "bootstrap_project", bootstrap)
|
||||
results = []
|
||||
threads = [
|
||||
threading.Thread(target=lambda: results.append(mod.resolve_project_id({})))
|
||||
for _ in range(3)
|
||||
]
|
||||
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
|
||||
assert results == ["project-1"] * 3
|
||||
assert calls == ["bootstrap"]
|
||||
|
||||
|
||||
def test_concurrent_auth_updates_are_atomic_and_merge_fields(tmp_path):
|
||||
mod = load_module()
|
||||
mod.AUTH_DIR = tmp_path / ".napseer"
|
||||
mod.AUTH_PATH = mod.AUTH_DIR / "auth.json"
|
||||
mod.VAULT_PATH = mod.AUTH_DIR / "vault.json"
|
||||
mod.AUTH_DIR.mkdir()
|
||||
mod.write_public_auth({"base_url": "https://api.example.test"})
|
||||
barrier = threading.Barrier(3)
|
||||
|
||||
def update(values):
|
||||
barrier.wait()
|
||||
mod.save_auth(values)
|
||||
|
||||
threads = [
|
||||
threading.Thread(target=update, args=({"project_slug": "demo"},)),
|
||||
threading.Thread(target=update, args=({"project_name": "Demo"},)),
|
||||
]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
barrier.wait()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
|
||||
saved = mod.load_public_auth_file()
|
||||
assert saved["project_slug"] == "demo"
|
||||
assert saved["project_name"] == "Demo"
|
||||
assert not list(mod.AUTH_DIR.glob(".auth.json.*.tmp"))
|
||||
|
||||
|
||||
def test_auth_renewal_reuses_token_refreshed_by_another_request(monkeypatch):
|
||||
mod = load_module()
|
||||
mod.TOKEN = "fresh-access"
|
||||
mod.TOKEN_EXPIRES_AT = "future"
|
||||
mod.REFRESH_EXPIRES_AT = "later"
|
||||
mod.DEFAULT_PROJECT_ID = "project-1"
|
||||
monkeypatch.setattr(mod, "refresh_public_auth_state", lambda: None)
|
||||
monkeypatch.setattr(
|
||||
mod,
|
||||
"_renew_auth_locked",
|
||||
lambda: (_ for _ in ()).throw(AssertionError("must not rotate again")),
|
||||
)
|
||||
|
||||
result = mod.renew_auth(stale_token="stale-access")
|
||||
|
||||
assert result == {
|
||||
"status": "already_renewed",
|
||||
"method": "concurrent_refresh",
|
||||
"token_expires_at": "future",
|
||||
"refresh_expires_at": "later",
|
||||
"project_id": "project-1",
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
"""Regression tests for account-login -> project-create authorization."""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
@@ -125,9 +126,40 @@ def test_server_scope_denial_is_actionable():
|
||||
raise AssertionError("scope denial should return an actionable error")
|
||||
|
||||
|
||||
def test_anonymous_limits_are_actionable_without_exposing_response_details():
|
||||
mod = load_module()
|
||||
|
||||
class HttpError:
|
||||
code = 403
|
||||
|
||||
for code, label in (
|
||||
("anonymous_space_limit_reached", "space"),
|
||||
("anonymous_node_limit_reached", "node"),
|
||||
):
|
||||
error = mod.safe_http_error(
|
||||
HttpError(),
|
||||
operation="POST request",
|
||||
body_text=json.dumps(
|
||||
{
|
||||
"error": {
|
||||
"code": code,
|
||||
"message": "server detail must not be forwarded",
|
||||
}
|
||||
}
|
||||
),
|
||||
)
|
||||
assert error.code == code
|
||||
assert error.status == 403
|
||||
assert error.service_code == code
|
||||
assert label in error.safe_message
|
||||
assert "nap project claim" in error.safe_message
|
||||
assert "server detail" not in error.safe_message
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_account_login_requests_create_and_runtime_scopes()
|
||||
test_legacy_account_scope_fails_before_http_with_recovery()
|
||||
test_successful_create_transitions_local_state_to_project_mode()
|
||||
test_server_scope_denial_is_actionable()
|
||||
test_anonymous_limits_are_actionable_without_exposing_response_details()
|
||||
print("ok: project create OAuth lifecycle passed")
|
||||
|
||||
Reference in New Issue
Block a user