#!/usr/bin/env python3 """Regression tests for account-login -> project-create authorization.""" import importlib.util import json import pathlib import sys PROJECT_ID = "33333333-3333-3333-3333-333333333333" 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_project_create_oauth_test", script_path) module = importlib.util.module_from_spec(spec) assert spec and spec.loader spec.loader.exec_module(module) return module def configure_account_state(mod, scope): mod.AUTH = { "account_mode": "operator_account", "oauth_scope": scope, } mod.TOKEN = "test-token" mod.BASE_URL = "https://api.example.test" mod.ensure_enrolled = lambda args: {"status": "already_enrolled"} mod.refresh_public_auth_state = lambda: mod.AUTH mod.register_project_signing_key = lambda args: None def project_args(): return { "slug": "created-project", "name": "Created Project", "description": "", } def test_account_login_requests_create_and_runtime_scopes(): mod = load_module() captured = [] saves = [] mod.AUTH_PATH = pathlib.Path("/tmp/napseer-test/auth.json") mod.oauth_loopback_authorize = lambda args: ( captured.append(dict(args)) or { "api_base_url": "https://api.example.test", "access_token": "test-token", "refresh_token": "test-refresh-token", "expires_in": 3600, "scope": args["scope"], } ) mod.replace_public_auth_state = lambda updates, clear_keys=(): saves.append(dict(updates)) result = mod.operator_account_login({"open_browser": False}) requested = set(captured[0]["scope"].split()) assert "napseer.projects.write" in requested assert set(mod.LOCAL_PROJECT_OAUTH_SCOPE.split()).issubset(requested) assert saves[0]["account_mode"] == "operator_account" assert "nap project create" in result["message"] def test_legacy_account_scope_fails_before_http_with_recovery(): mod = load_module() configure_account_state(mod, "openid profile email napseer.projects.read") requests = [] mod.request_json = lambda *args, **kwargs: requests.append((args, kwargs)) try: mod.create_project_with_state(project_args()) except mod.SafeToolError as exc: assert exc.code == "project_create_scope_required" assert exc.status == 403 assert "nap auth login" in exc.safe_message else: raise AssertionError("legacy account scope should require a new login") assert not requests def test_successful_create_transitions_local_state_to_project_mode(): mod = load_module() configure_account_state(mod, mod.OPERATOR_ACCOUNT_OAUTH_SCOPE) saves = [] mod.request_json = lambda method, path, payload=None: { "id": PROJECT_ID, "slug": payload["slug"], "name": payload["name"], } mod.save_auth = lambda updates: saves.append(dict(updates)) project, status, _message = mod.create_project_with_state(project_args()) assert status == "created" assert project["id"] == PROJECT_ID assert saves[0]["project_id"] == PROJECT_ID assert saves[0]["account_mode"] == "operator_project" def test_server_scope_denial_is_actionable(): mod = load_module() configure_account_state(mod, mod.OPERATOR_ACCOUNT_OAUTH_SCOPE) def denied(*args, **kwargs): raise mod.SafeToolError( "http_403", "Napseer POST request failed with HTTP 403.", status=403, service_code="insufficient_scope", ) mod.request_json = denied try: mod.create_project_with_state(project_args()) except mod.SafeToolError as exc: assert exc.code == "project_create_scope_required" assert "nap auth login" in exc.safe_message else: 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")