"""Login in a checkout with a committed locator rebinds its project without a second, project-scoped consent, and never replaces a still-valid override.""" import importlib.util import json import sys import threading from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import parse_qs import pytest SCRIPT = Path(__file__).resolve().parents[1] / "resources/scripts/napseer_mcp_server.py" sys.path.insert(0, str(SCRIPT.parent)) from napseer_credentials import CredentialStore, read_json # noqa: E402 PROJECT_ID = "33333333-3333-3333-3333-333333333333" PROJECT = {"id": PROJECT_ID, "slug": "committed-project", "name": "Committed Project", "encryption_state": "standard"} @pytest.fixture def provider(): state = {"refresh": [], "project_reads": [], "token_status": 401} class Handler(BaseHTTPRequestHandler): def log_message(self, *_args): pass def reply(self, status, value): data = json.dumps(value).encode() self.send_response(status) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(data))) self.end_headers() self.wfile.write(data) def do_GET(self): if self.path == f"/v1/projects/{PROJECT_ID}": state["project_reads"].append(self.headers.get("Authorization")) if self.headers.get("Authorization") == "Bearer account-access": return self.reply(200, PROJECT) self.reply(401, {"error": {"code": "unauthorized"}}) def do_POST(self): form = parse_qs(self.rfile.read(int(self.headers["Content-Length"])).decode()) assert self.path == "/v1/oauth/token" state["refresh"].append(form.get("refresh_token")) if state["token_status"] == 503: return self.reply(503, {"error": {"code": "unavailable"}}) self.reply(401, {"error": {"code": "invalid_grant"}}) server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() try: yield f"http://127.0.0.1:{server.server_port}", state finally: server.shutdown() server.server_close() thread.join(timeout=2) def checkout(tmp_path, monkeypatch, url, project_credential): root = tmp_path / "checkout" (root / ".napseer").mkdir(parents=True) (root / ".napseer/project.json").write_text(json.dumps({ "schema": "napseer.project.v1", "base_url": url, "project_id": PROJECT_ID, "project_slug": PROJECT["slug"]}) + "\n") monkeypatch.setenv("NAPSEER_PROJECT_ROOT", str(root)) monkeypatch.setenv("NAPSEER_BASE_URL", url) monkeypatch.chdir(root) spec = importlib.util.spec_from_file_location(f"committed_login_{id(root)}", SCRIPT) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) store = module.credential_store() if project_credential is not None: store.login({"base_url": url, "account_mode": "operator_project", "oauth_client_id": "nap-cli", "project_id": PROJECT_ID, **project_credential}, project=True) oauth_calls = [] def consent(args): oauth_calls.append(args["flow"]) assert args["flow"] == "operator_login", "a committed project must not need project consent" return {"api_base_url": url, "access_token": "account-access", "refresh_token": "account-refresh", "expires_in": 3600, "scope": args["scope"]} module.oauth_loopback_authorize = consent return module, store, oauth_calls def test_login_replaces_revoked_project_grant_and_attaches_committed_project(tmp_path, monkeypatch, provider): url, state = provider module, store, oauth_calls = checkout(tmp_path, monkeypatch, url, { "token": "dead-access", "refresh_token": "dead-refresh", "token_expires_at": "2020-01-01T00:00:00Z", "refresh_expires_at": "2099-01-01T00:00:00Z"}) result = module.operator_account_login({"base_url": url}) assert oauth_calls == ["operator_login"] assert state["refresh"] == [["dead-refresh"]], "the dead grant is proven dead before it is replaced" assert result["replaced_project_credential"] == "refresh_rejected" assert result["credential_source"] == "user" assert result["project_id"] == PROJECT_ID assert store.selected()[1] == "user" assert read_json(store.selection)["project_id"] == PROJECT_ID assert state["project_reads"][-1] == "Bearer account-access" assert "dead-refresh" not in json.dumps(result) def test_login_keeps_valid_project_override(tmp_path, monkeypatch, provider): url, state = provider module, store, oauth_calls = checkout(tmp_path, monkeypatch, url, { "token": "live-access", "refresh_token": "live-refresh", "token_expires_at": "2099-01-01T00:00:00Z", "refresh_expires_at": "2099-01-01T00:00:00Z"}) result = module.operator_account_login({"base_url": url}) assert oauth_calls == ["operator_login"] assert result["credential_source"] == "project" assert "use-user" in result["message"] assert store.selected()[1] == "project" assert state["refresh"] == [] and state["project_reads"] == [] def test_login_does_not_replace_override_when_refresh_outcome_is_unknown(tmp_path, monkeypatch, provider): url, state = provider state["token_status"] = 503 module, store, _calls = checkout(tmp_path, monkeypatch, url, { "token": "dead-access", "refresh_token": "dead-refresh", "token_expires_at": "2020-01-01T00:00:00Z", "refresh_expires_at": "2099-01-01T00:00:00Z"}) result = module.operator_account_login({"base_url": url}) assert result["credential_source"] == "project" assert store.selected()[1] == "project" def test_login_without_override_attaches_committed_project(tmp_path, monkeypatch, provider): url, state = provider module, store, oauth_calls = checkout(tmp_path, monkeypatch, url, None) result = module.operator_account_login({"base_url": url}) assert oauth_calls == ["operator_login"] assert result["project_id"] == PROJECT_ID assert read_json(store.selection)["project_id"] == PROJECT_ID def test_attach_uses_account_login_when_project_grant_was_logged_out(tmp_path, monkeypatch, provider): url, state = provider module, store, oauth_calls = checkout(tmp_path, monkeypatch, url, None) store.login({"base_url": url, "token": "account-access", "refresh_token": "account-refresh", "account_mode": "operator_account", "oauth_client_id": "nap-cli", "token_expires_at": "2099-01-01T00:00:00Z"}) store.login({"base_url": url, "logged_out": True}, project=True) result = module.operator_project_attach({}) assert oauth_calls == [], "no browser consent is needed for a committed project" assert result["mode"] == "operator_account" assert result["project_id"] == PROJECT_ID assert store.selected()[1] == "user" def test_project_grant_rejection_explains_reattach(tmp_path, monkeypatch, provider): url, _state = provider module, _store, _calls = checkout(tmp_path, monkeypatch, url, { "token": "dead-access", "refresh_token": "dead-refresh", "token_expires_at": "2020-01-01T00:00:00Z", "refresh_expires_at": "2099-01-01T00:00:00Z"}) module.refresh_public_auth_state() with pytest.raises(module.SafeToolError) as error: module.renew_auth() assert "project login" in error.value.safe_message assert ".napseer/project.json" in error.value.safe_message