From c69298a3f9644ee512697b6ced8e733dbf2f213c Mon Sep 17 00:00:00 2001 From: abhijitt-code Date: Mon, 24 Aug 2026 17:06:53 +0530 Subject: [PATCH 1/2] Raise Phase 6 test coverage with JWT, HTTP lifecycle, and CI matrix. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close remaining SDK test gaps (JWT/config/guards, nested modules, HTTP JWT wiring) and add pytest-cov plus the 3.10–3.12 CI/tox matrix per dev-plan/PHASE-6-testing-coverage.md. --- .github/workflows/test.yml | 25 +- nitrostack/__init__.py | 36 +++ nitrostack/auth/oauth.py | 75 +++++- nitrostack/auth/scopes.py | 62 +++++ nitrostack/core/app.py | 142 ++++++++-- nitrostack/core/di.py | 17 +- nitrostack/core/errors.py | 23 ++ nitrostack/core/pipeline.py | 12 +- nitrostack/events/event_emitter.py | 59 +++- pyproject.toml | 32 +++ pytest.ini | 7 + tests/test_app_mode.py | 34 +++ tests/test_auth_modules.py | 175 ++++++++++++ tests/test_basic.py | 4 + tests/test_cli_install.py | 74 +++++ tests/test_decorators_extra.py | 137 ++++++++++ tests/test_di_edge_cases.py | 122 +++++++++ tests/test_error_handling.py | 161 +++++++++++ tests/test_events.py | 83 ++++++ tests/test_lifecycle_http.py | 232 ++++++++++++++++ tests/test_logger.py | 44 +++ tests/test_module.py | 59 ++++ tests/test_oauth.py | 417 +++++++++++++++++++++++++++++ tests/test_oauth_discovery.py | 96 +++++++ tests/test_pipeline_order.py | 287 ++++++++++++++++++++ tests/test_pkce.py | 45 ++++ tests/test_scopes.py | 67 +++++ tests/test_stdio.py | 36 +++ tests/test_testing_module.py | 69 +++++ tests/test_transport_http.py | 124 +++++++++ tox.ini | 7 + 31 files changed, 2706 insertions(+), 57 deletions(-) create mode 100644 nitrostack/auth/scopes.py create mode 100644 pytest.ini create mode 100644 tests/test_app_mode.py create mode 100644 tests/test_auth_modules.py create mode 100644 tests/test_cli_install.py create mode 100644 tests/test_decorators_extra.py create mode 100644 tests/test_di_edge_cases.py create mode 100644 tests/test_error_handling.py create mode 100644 tests/test_events.py create mode 100644 tests/test_lifecycle_http.py create mode 100644 tests/test_logger.py create mode 100644 tests/test_module.py create mode 100644 tests/test_oauth_discovery.py create mode 100644 tests/test_pipeline_order.py create mode 100644 tests/test_pkce.py create mode 100644 tests/test_scopes.py create mode 100644 tests/test_stdio.py create mode 100644 tests/test_testing_module.py create mode 100644 tests/test_transport_http.py create mode 100644 tox.ini diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a1d8741..fad9904 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,9 +2,9 @@ name: Python SDK Test Suite on: push: - branches: [ main, master ] + branches: [ main, master, develop ] pull_request: - branches: [ main, master ] + branches: [ main, master, develop ] jobs: test: @@ -14,28 +14,17 @@ jobs: python-version: ["3.10", "3.11", "3.12"] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip - pip install pytest - pip install -e . + pip install -e ".[dev]" - - name: Run Integration Tests - run: | - python tests/test_basic.py - python tests/test_tasks.py - python tests/test_initial_tool.py - python tests/test_oauth.py - python tests/test_production.py - python tests/test_widget_metadata.py - python tests/test_transports.py - python tests/test_cli.py - pytest tests/test_cli.py -v - python tests/test_tool_input_schema.py + - name: Run pytest with coverage + run: pytest tests --cov=nitrostack --cov-report=term-missing diff --git a/nitrostack/__init__.py b/nitrostack/__init__.py index 25b4a8a..37df45d 100644 --- a/nitrostack/__init__.py +++ b/nitrostack/__init__.py @@ -48,6 +48,11 @@ TaskAlreadyTerminalError, InvalidTaskTransitionError, TaskExpiredError, + ConfigurationError, + DependencyResolutionError, + OAuthError, + TokenInactiveError, + AudienceMismatchError, ) from nitrostack.core.pipeline import ( use_guards, @@ -77,6 +82,21 @@ from nitrostack.auth.oauth import ( OAuthModule, OAuthService, + generate_www_authenticate_header, +) +from nitrostack.auth.pkce import ( + generate_code_challenge, + generate_code_verifier, + generate_pkce_params, + is_valid_code_verifier, + validate_pkce_support, + verify_pkce, +) +from nitrostack.auth.scopes import ( + has_all_scopes, + has_any_scope, + has_scope, + require_scopes, ) from nitrostack.auth.config import ( ConfigModule, @@ -144,6 +164,22 @@ "JWTModule", "OAuthModule", "OAuthService", + "generate_www_authenticate_header", + "generate_code_challenge", + "generate_code_verifier", + "generate_pkce_params", + "is_valid_code_verifier", + "validate_pkce_support", + "verify_pkce", + "has_all_scopes", + "has_any_scope", + "has_scope", + "require_scopes", + "ConfigurationError", + "DependencyResolutionError", + "OAuthError", + "TokenInactiveError", + "AudienceMismatchError", "ConfigModule", "ConfigService", "NitroTestingModule", diff --git a/nitrostack/auth/oauth.py b/nitrostack/auth/oauth.py index 54cabfc..3d75531 100644 --- a/nitrostack/auth/oauth.py +++ b/nitrostack/auth/oauth.py @@ -15,6 +15,7 @@ build_registration_response, is_client_registration_enabled, ) +from nitrostack.core.errors import AudienceMismatchError, ConfigurationError, TokenInactiveError def is_oauth_required() -> bool: @@ -29,6 +30,25 @@ def is_oauth_required() -> bool: _oauth_fail_open_warned = False +def generate_www_authenticate_header( + *, + realm: str = "mcp", + resource_metadata: Optional[str] = None, + error: Optional[str] = None, + error_description: Optional[str] = None, +) -> str: + """RFC 6750 / RFC 9728 WWW-Authenticate value for protected MCP resources.""" + parts = [f'Bearer realm="{realm}"'] + if resource_metadata: + parts.append(f'resource_metadata="{resource_metadata}"') + if error: + parts.append(f'error="{error}"') + if error_description: + escaped = error_description.replace('"', "'") + parts.append(f'error_description="{escaped}"') + return ", ".join(parts) + + def warn_if_oauth_fail_open() -> None: """Loud warning when OAuth is wired but tokens are not enforced.""" global _oauth_fail_open_warned @@ -125,16 +145,35 @@ def start_discovery_server(self) -> None: registration_path = "/oauth/v2/register" def _write_json(handler: BaseHTTPRequestHandler, status: int, payload: Dict[str, Any]) -> None: + raw = json.dumps(payload).encode("utf-8") handler.send_response(status) handler.send_header("Content-Type", "application/json") + handler.send_header("Content-Length", str(len(raw))) + handler.send_header("Connection", "close") + handler.end_headers() + handler.wfile.write(raw) + + def _write_empty(handler: BaseHTTPRequestHandler, status: int) -> None: + handler.send_response(status) + handler.send_header("Content-Length", "0") + handler.send_header("Connection", "close") handler.end_headers() - handler.wfile.write(json.dumps(payload).encode("utf-8")) class DiscoveryHandler(BaseHTTPRequestHandler): def log_message(self, format, *args): # Suppress server logging to stdout/stderr to keep stdio clean pass + def do_OPTIONS(self): + # TS discovery handlers honor CORS preflight without leaking credentials. + self.send_response(200) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization") + self.send_header("Content-Length", "0") + self.send_header("Connection", "close") + self.end_headers() + def do_GET(self): if self.path == "/.well-known/oauth-protected-resource": _write_json(self, 200, build_protected_resource_metadata(service_instance)) @@ -148,13 +187,11 @@ def do_GET(self): build_authorization_server_metadata(service_instance, registration_endpoint), ) else: - self.send_response(404) - self.end_headers() + _write_empty(self, 404) def do_POST(self): if self.path != registration_path: - self.send_response(404) - self.end_headers() + _write_empty(self, 404) return if not is_client_registration_enabled(service_instance): @@ -197,6 +234,28 @@ def run_server(): self._thread = threading.Thread(target=run_server, daemon=True) self._thread.start() + def _audience_ok(self, token_info: Dict[str, Any]) -> bool: + """RFC 8707 resource indicator / JWT ``aud`` must match configured audience.""" + expected = self.audience + if not expected: + return True + actual = token_info.get("aud", token_info.get("resource")) + if actual is None: + return False + if isinstance(actual, list): + return expected in actual + return actual == expected + + def raise_if_invalid(self, token_info: Dict[str, Any]) -> Dict[str, Any]: + """Raise ``TokenInactiveError`` / ``AudienceMismatchError`` for filter tests.""" + if token_info.get("error") == "audience_mismatch" or ( + token_info.get("active") and not self._validate_audience(token_info) + ): + raise AudienceMismatchError(self.audience, token_info.get("aud") or token_info.get("resource")) + if not token_info.get("active"): + raise TokenInactiveError() + return token_info + def stop_discovery_server(self) -> None: if self._server: self._server.shutdown() @@ -379,6 +438,12 @@ def for_root( static_client_id: Optional[str] = None, static_client_secret: Optional[str] = None, ): + if not resource_uri or not str(resource_uri).strip(): + raise ConfigurationError("OAuthModule.for_root requires resource_uri") + if not authorization_servers: + raise ConfigurationError( + "OAuthModule.for_root requires at least one authorization server" + ) service = OAuthService( resource_uri=resource_uri, authorization_servers=authorization_servers, diff --git a/nitrostack/auth/scopes.py b/nitrostack/auth/scopes.py new file mode 100644 index 0000000..efbf8fb --- /dev/null +++ b/nitrostack/auth/scopes.py @@ -0,0 +1,62 @@ +"""Scope helpers for AuthContext (TS ``hasScope`` / ``@RequireScopes``).""" +from __future__ import annotations + +from functools import wraps +from typing import Any, Callable, Iterable, Sequence + +from nitrostack.core.context import AuthContext, ExecutionContext + + +def _scope_list(auth: AuthContext | None) -> list: + if auth is None: + return [] + scopes = getattr(auth, "scopes", None) or [] + if isinstance(scopes, str): + return [part for part in scopes.split() if part] + return list(scopes) + + +def has_scope(auth: AuthContext | None, scope: str) -> bool: + return scope in _scope_list(auth) + + +def has_any_scope(auth: AuthContext | None, scopes: Iterable[str]) -> bool: + have = set(_scope_list(auth)) + return any(scope in have for scope in scopes) + + +def has_all_scopes(auth: AuthContext | None, scopes: Iterable[str]) -> bool: + have = set(_scope_list(auth)) + return all(scope in have for scope in scopes) + + +def require_scopes(*needed: str) -> Callable: + """Reject the handler when ``context.auth`` is missing any of ``needed``.""" + + def decorator(func: Callable) -> Callable: + @wraps(func) + async def wrapper(*args: Any, **kwargs: Any): + context = kwargs.get("context") + if context is None: + for arg in args: + if isinstance(arg, ExecutionContext): + context = arg + break + auth = getattr(context, "auth", None) if context is not None else None + if auth is None: + raise PermissionError("Not authenticated") + if needed and not has_all_scopes(auth, needed): + raise PermissionError(f"Missing required scopes: {', '.join(needed)}") + return await func(*args, **kwargs) + + return wrapper + + return decorator + + +def scopes_from_sequence(values: Sequence[str] | str | None) -> list: + if values is None: + return [] + if isinstance(values, str): + return [part for part in values.split() if part] + return list(values) diff --git a/nitrostack/core/app.py b/nitrostack/core/app.py index 53b11d6..a0a3f86 100644 --- a/nitrostack/core/app.py +++ b/nitrostack/core/app.py @@ -22,6 +22,7 @@ from nitrostack.core.app_mode import get_app_mode, get_widget_mime_type, is_mcp_app_mode, is_openai_mode from nitrostack.core.di import DIContainer from nitrostack.core.errors import ( + DependencyResolutionError, PromptNotFoundError, ResourceNotFoundError, TaskAlreadyTerminalError, @@ -238,6 +239,74 @@ class _PromptEntry: method: Callable +_AUTH_META_KEYS = ("authorization", "x-api-key", "token", "_oauth", "headers") + + +def _auth_metadata_from_request_ctx(rc: Any) -> Dict[str, Any]: + """Copy host-sent auth slots from MCP request ``_meta`` into ExecutionContext.""" + extra: Dict[str, Any] = {} + if rc is None: + return extra + + raw_meta = getattr(rc, "meta", None) + data: Dict[str, Any] = {} + if raw_meta is not None: + extra_fields = getattr(raw_meta, "model_extra", None) or getattr(raw_meta, "__pydantic_extra__", None) + if isinstance(extra_fields, dict): + data.update(extra_fields) + if hasattr(raw_meta, "model_dump"): + try: + dumped = raw_meta.model_dump(exclude_none=True) + if isinstance(dumped, dict): + data.update(dumped) + except Exception: + pass + elif isinstance(raw_meta, dict): + data.update(raw_meta) + else: + for key in _AUTH_META_KEYS: + value = getattr(raw_meta, key, None) + if value is not None: + data[key] = value + + auth = data.get("authorization") or data.get("Authorization") + if isinstance(auth, str) and auth.strip(): + extra["authorization"] = auth + api_key = data.get("x-api-key") + if isinstance(api_key, str) and api_key.strip(): + extra["x-api-key"] = api_key + token = data.get("token") + if isinstance(token, str) and token.strip(): + extra["token"] = token + oauth = data.get("_oauth") + if isinstance(oauth, str) and oauth.strip(): + extra["_oauth"] = oauth + headers = data.get("headers") + if isinstance(headers, dict): + extra["headers"] = headers + if "authorization" not in extra: + header_auth = headers.get("authorization") or headers.get("Authorization") + if isinstance(header_auth, str) and header_auth.strip(): + extra["authorization"] = header_auth + if "x-api-key" not in extra: + header_key = headers.get("x-api-key") or headers.get("X-API-Key") + if isinstance(header_key, str) and header_key.strip(): + extra["x-api-key"] = header_key + + request = getattr(rc, "request", None) + headers_obj = getattr(request, "headers", None) if request is not None else None + if headers_obj is not None: + try: + http_auth = headers_obj.get("authorization") or headers_obj.get("Authorization") + if isinstance(http_auth, str) and http_auth.strip() and "authorization" not in extra: + extra["authorization"] = http_auth + http_key = headers_obj.get("x-api-key") + if isinstance(http_key, str) and http_key.strip() and "x-api-key" not in extra: + extra["x-api-key"] = http_key + except Exception: + pass + return extra + class McpApplication: def __init__(self, app_class: Type): self.app_class = app_class @@ -277,6 +346,7 @@ def _bootstrap(self) -> None: self._resolve_modules(self.root_module, resolved_modules) container = DIContainer.get_instance() + self._assert_declared_dependencies(resolved_modules, container) # Instantiate all providers and controllers to populate container for mod in resolved_modules: @@ -327,6 +397,37 @@ def _bootstrap(self) -> None: # instances (e.g. one per HTTP session) can be configured identically. self._setup_handlers(self.mcp_server) + def _assert_declared_dependencies( + self, resolved_modules: Set[Type], container: DIContainer + ) -> None: + """Fail at bootstrap when a string ``deps=[...]`` token was never registered.""" + missing: List[str] = [] + seen: Set[Type] = set() + + def walk(cls: Any) -> None: + if not isinstance(cls, type) or cls in seen: + return + seen.add(cls) + for dep in getattr(cls, "_mcp_deps", []) or []: + if isinstance(dep, str): + if not container.has_value(dep) and dep not in container._registry: + missing.append(f"{cls.__name__} -> '{dep}'") + elif isinstance(dep, type): + walk(dep) + + for mod in resolved_modules: + mod_config = getattr(mod, "_mcp_module_config", None) + if not mod_config: + continue + for cls in [*mod_config.providers, *mod_config.controllers]: + walk(cls) + + if missing: + raise DependencyResolutionError( + "Missing dependency at app bootstrap (referenced in deps=[...] " + "but never registered): " + "; ".join(missing) + ) + def _resolve_modules(self, module_class: Type, resolved_modules: Set[Type]) -> None: if module_class in resolved_modules: return @@ -709,6 +810,7 @@ def _widget_result_content(self, structured: Dict[str, Any], component: Optional ) return content + async def _call_tool(self, name: str, arguments: Dict[str, Any]): entry = self._tools.get(name) if entry is None: @@ -738,6 +840,7 @@ async def _call_tool(self, name: str, arguments: Dict[str, Any]): if getattr(rc, "meta", None) is not None: progress_token = rc.meta.progressToken session = getattr(rc, "session", None) + auth_meta = _auth_metadata_from_request_ctx(rc) is_task = (task_metadata is not None) or (cfg.task_support == "required") if cfg.task_support == "forbidden": @@ -752,7 +855,7 @@ async def background_execution(): task_ctx = ExecutionContext( request_id=str(uuid.uuid4()), tool_name=cfg.name, - metadata={"input": input_instance}, + metadata={"input": input_instance, **auth_meta}, ) task_ctx.task = TaskContext( task_id, @@ -788,21 +891,28 @@ async def background_execution(): asyncio.create_task(background_execution()) return types.CreateTaskResult(task=self._task_data_to_mcp_task(task)) - ctx = ExecutionContext(request_id=str(uuid.uuid4()), tool_name=cfg.name, metadata={"input": input_instance}) - result = await run_pipeline( - handler=entry.method, - handler_instance=entry.instance, - args=(input_instance, ctx), - kwargs={}, - context=ctx, - guards=guards, - middleware=middleware, - interceptors=interceptors, - pipes=pipes, - filters=filters, - param_name="input", - param_type=entry.input_model, - ) + ctx = ExecutionContext(request_id=str(uuid.uuid4()), tool_name=cfg.name, metadata={"input": input_instance, **auth_meta}) + try: + result = await run_pipeline( + handler=entry.method, + handler_instance=entry.instance, + args=(input_instance, ctx), + kwargs={}, + context=ctx, + guards=guards, + middleware=middleware, + interceptors=interceptors, + pipes=pipes, + filters=filters, + param_name="input", + param_type=entry.input_model, + ) + except Exception as exc: + logger.exception("Tool %s failed", cfg.name) + return types.CallToolResult( + content=[types.TextContent(type="text", text=str(exc))], + isError=True, + ) return self._to_call_tool_result(result, entry.component, ctx) async def _read_resource(self, uri: str) -> List[ReadResourceContents]: diff --git a/nitrostack/core/di.py b/nitrostack/core/di.py index f57d61a..c1c11c2 100644 --- a/nitrostack/core/di.py +++ b/nitrostack/core/di.py @@ -17,6 +17,7 @@ def reset(cls) -> None: def __init__(self): self._registry: Dict[Any, Type] = {} self._instances: Dict[Any, Any] = {} + self._resolving: set = set() def register(self, cls: Type) -> None: """Register a provider class.""" @@ -59,17 +60,25 @@ def resolve(self, token: Any) -> Any: if cls is None: raise DependencyResolutionError(f"Dependency '{token}' is not registered in the DIContainer.") + cycle_key = cls + if cycle_key in self._resolving: + chain = " -> ".join(getattr(item, "__name__", str(item)) for item in (*self._resolving, cycle_key)) + raise DependencyResolutionError(f"Circular dependency detected: {chain}") + # 4. Resolve dependencies of the class deps = getattr(cls, "_mcp_deps", []) resolved_args = [] - for dep in deps: - resolved_args.append(self.resolve(dep)) - - # 5. Instantiate the class + self._resolving.add(cycle_key) try: + for dep in deps: + resolved_args.append(self.resolve(dep)) instance = cls(*resolved_args) + except DependencyResolutionError: + raise except Exception as e: raise DependencyResolutionError(f"Failed to instantiate class '{cls.__name__}' due to: {e}") from e + finally: + self._resolving.discard(cycle_key) # 6. Cache and return the singleton instance self._instances[token] = instance diff --git a/nitrostack/core/errors.py b/nitrostack/core/errors.py index cfcf5bf..b5f1c50 100644 --- a/nitrostack/core/errors.py +++ b/nitrostack/core/errors.py @@ -30,6 +30,29 @@ class ConfigurationError(Exception): pass +class OAuthError(Exception): + """Base class for OAuth / token-validation failures.""" + + +class TokenInactiveError(OAuthError): + """Raised when introspection or JWT verification reports an inactive token.""" + + def __init__(self, message: str = "OAuth token is inactive or revoked"): + super().__init__(message) + + +class AudienceMismatchError(OAuthError): + """Raised when a token audience / resource indicator does not match.""" + + def __init__(self, expected: Any = None, actual: Any = None): + self.expected = expected + self.actual = actual + if expected is not None: + super().__init__(f"Token audience mismatch: expected {expected!r}, got {actual!r}") + else: + super().__init__("Token audience mismatch") + + class TaskNotFoundError(Exception): """Raised when a task ID is not present in the TaskManager store.""" diff --git a/nitrostack/core/pipeline.py b/nitrostack/core/pipeline.py index 337fa51..6bc5091 100644 --- a/nitrostack/core/pipeline.py +++ b/nitrostack/core/pipeline.py @@ -249,14 +249,18 @@ async def call_target(): current_next = call_target for interceptor_cls in reversed(interceptors): interceptor = container.resolve(interceptor_cls) - def make_interceptor_next(nxt): - return lambda: interceptor.intercept(context, nxt) + + def make_interceptor_next(nxt, icpt=interceptor): + return lambda: icpt.intercept(context, nxt) + current_next = make_interceptor_next(current_next) for middleware_cls in reversed(middleware): mw = container.resolve(middleware_cls) - def make_middleware_next(nxt): - return lambda: mw.use(context, nxt) + + def make_middleware_next(nxt, middleware=mw): + return lambda: middleware.use(context, nxt) + current_next = make_middleware_next(current_next) # Execute the chain diff --git a/nitrostack/events/event_emitter.py b/nitrostack/events/event_emitter.py index c2b409c..1e6f986 100644 --- a/nitrostack/events/event_emitter.py +++ b/nitrostack/events/event_emitter.py @@ -1,6 +1,7 @@ -import sys import inspect -from typing import Any, Callable, Dict, List, Tuple, Optional +import sys +from typing import Any, Callable, Dict, List, Optional, Tuple + class EventEmitter: _instance = None @@ -29,24 +30,62 @@ def register_listener(self, event_name: str, func: Callable, class_type: Optiona def bind_instance(self, event_name: str, func: Callable, instance: Any) -> None: if event_name not in self._bound_listeners: self._bound_listeners[event_name] = [] - - # Bind the unbound method to the class instance - # Standard Python descriptor binding: func.__get__(instance, type(instance)) + bound_func = func.__get__(instance, type(instance)) self._bound_listeners[event_name].append(bound_func) + def on(self, event_name: str, listener: Callable) -> None: + self._bound_listeners.setdefault(event_name, []).append(listener) + + def off(self, event_name: str, listener: Callable) -> None: + bound = self._bound_listeners.get(event_name) + if not bound: + return + self._bound_listeners[event_name] = [item for item in bound if item is not listener] + + def once(self, event_name: str, listener: Callable) -> Callable: + def wrapper(payload: Any): + self.off(event_name, wrapper) + return listener(payload) + + self.on(event_name, wrapper) + return wrapper + + def listener_count(self, event_name: str) -> int: + return len(self._bound_listeners.get(event_name, [])) + + def event_names(self) -> List[str]: + return [name for name, items in self._bound_listeners.items() if items] + + def remove_all_listeners(self, event_name: Optional[str] = None) -> None: + if event_name is None: + self._bound_listeners.clear() + return + self._bound_listeners.pop(event_name, None) + async def emit(self, event_name: str, payload: Any) -> None: - listeners = self._bound_listeners.get(event_name, []) + listeners = list(self._bound_listeners.get(event_name, [])) for listener in listeners: try: - if inspect.iscoroutinefunction(listener): - await listener(payload) - else: - listener(payload) + result = listener(payload) + if inspect.iscoroutine(result): + await result except Exception as e: sys.stderr.write(f"Event emitter error: handler for '{event_name}' failed: {e}\n") sys.stderr.flush() + def emit_sync(self, event_name: str, payload: Any) -> None: + listeners = list(self._bound_listeners.get(event_name, [])) + for listener in listeners: + try: + result = listener(payload) + if inspect.iscoroutine(result): + result.close() + except Exception as e: + sys.stderr.write(f"Event emitter error: handler for '{event_name}' failed: {e}\n") + sys.stderr.flush() + + def on_event(event_name: str): """ Decorator to mark a service or controller method as an event listener. diff --git a/pyproject.toml b/pyproject.toml index dcaaabe..e5d5550 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,14 @@ dependencies = [ "tomli>=2.0.0; python_version < '3.11'", ] +[project.optional-dependencies] +dev = [ + "pytest>=7.4.0", + "pytest-cov>=4.1.0", + "httpx>=0.27.0", + "tox>=4.0.0", +] + [project.scripts] nitrostack-py = "nitrostack.cli.main:main" @@ -48,3 +56,27 @@ python_version = "3.10" warn_unused_configs = true ignore_missing_imports = true strict_optional = true + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_functions = ["test_*"] +addopts = "-q" +filterwarnings = ["ignore::DeprecationWarning"] + +[tool.coverage.run] +source = ["nitrostack"] +omit = [ + "nitrostack/templates/*", + "nitrostack/cli/templates/*", + "*/tests/*", +] + +[tool.coverage.report] +show_missing = true +skip_empty = true +exclude_lines = [ + "pragma: no cover", + "if TYPE_CHECKING:", + "if __name__ == .__main__.:", +] diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..ff8dd15 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,7 @@ +[pytest] +testpaths = tests +python_files = test_*.py +python_functions = test_* +addopts = -q +filterwarnings = + ignore::DeprecationWarning diff --git a/tests/test_app_mode.py b/tests/test_app_mode.py new file mode 100644 index 0000000..e11c0e1 --- /dev/null +++ b/tests/test_app_mode.py @@ -0,0 +1,34 @@ +"""Phase 4 — NITROSTACK_APP_MODE parsing (Python-only).""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from nitrostack.core.app_mode import ( + RESOURCE_MIME_TYPE_MCP_APP, + RESOURCE_MIME_TYPE_OPENAI, + get_app_mode, + get_widget_mime_type, + is_mcp_app_mode, + is_openai_mode, +) + + +def test_default_mode_is_universal(monkeypatch): + monkeypatch.delenv("NITROSTACK_APP_MODE", raising=False) + assert get_app_mode() == "universal" + assert is_mcp_app_mode() is True + assert is_openai_mode() is True + assert get_widget_mime_type() == RESOURCE_MIME_TYPE_MCP_APP + + +def test_mcp_app_and_openai_aliases(monkeypatch): + monkeypatch.setenv("NITROSTACK_APP_MODE", "mcp-app") + assert get_app_mode() == "mcp-app" + assert is_openai_mode() is False + monkeypatch.setenv("NITROSTACK_APP_MODE", "openai") + assert get_app_mode() == "openai" + assert is_mcp_app_mode() is False + assert get_widget_mime_type() == RESOURCE_MIME_TYPE_OPENAI diff --git a/tests/test_auth_modules.py b/tests/test_auth_modules.py new file mode 100644 index 0000000..692fc85 --- /dev/null +++ b/tests/test_auth_modules.py @@ -0,0 +1,175 @@ +"""Phase 2 gaps — API key, JWT, and Config modules (Python-only).""" +from __future__ import annotations + +import os +import time +import sys + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from nitrostack import ConfigModule, ConfigService, DIContainer +from nitrostack.auth.api_key import ApiKeyModule, ApiKeyService +from nitrostack.auth.jwt import JWTModule, JWTService +from nitrostack.core.errors import ConfigurationError + + +def setup_function() -> None: + DIContainer.reset() + + +def teardown_function() -> None: + DIContainer.reset() + + +def test_api_key_loads_numbered_suffixes(monkeypatch): + monkeypatch.delenv("API_KEY", raising=False) + monkeypatch.setenv("API_KEY_1", "alpha") + monkeypatch.setenv("API_KEY_2", "beta") + ApiKeyModule.for_root(hashed=False) + service = DIContainer.get_instance().resolve(ApiKeyService) + assert service.validate("alpha") is True + assert service.validate("beta") is True + assert service.validate("nope") is False + + +def test_api_key_validate_plain_and_hashed(monkeypatch): + monkeypatch.setenv("API_KEY", "plain-secret") + ApiKeyModule.for_root(hashed=False) + service = DIContainer.get_instance().resolve(ApiKeyService) + assert service.validate("plain-secret") is True + assert service.validate("wrong") is False + generated = service.generate_key("sk") + assert generated.startswith("sk_") + + DIContainer.reset() + hashed = service.hash_key("plain-secret") + monkeypatch.setenv("API_KEY", hashed) + ApiKeyModule.for_root(hashed=True) + hashed_service = DIContainer.get_instance().resolve(ApiKeyService) + assert hashed_service.validate("plain-secret") is True + assert hashed_service.validate("nope") is False + + +def test_jwt_create_and_verify_roundtrip(monkeypatch): + monkeypatch.setenv("JWT_SECRET", "unit-test-secret") + JWTModule.for_root(secret_env_var="JWT_SECRET", audience="mcp", issuer="nitro") + jwt = DIContainer.get_instance().resolve(JWTService) + token = jwt.create_token({"sub": "user-1"}) + payload = jwt.verify_token(token) + assert payload["sub"] == "user-1" + assert payload["aud"] == "mcp" + assert payload["iss"] == "nitro" + try: + jwt.verify_token("not.a.jwt") + raise AssertionError("invalid jwt should fail") + except ValueError: + pass + + +def test_config_module_defaults_and_validation_error(tmp_path): + env_file = tmp_path / ".env" + env_file.write_text("APP_NAME=phase6\n", encoding="utf-8") + ConfigModule.for_root(env_file_path=str(env_file), defaults={"PORT": "3000"}) + cfg = DIContainer.get_instance().resolve(ConfigService) + assert cfg.get("APP_NAME") == "phase6" + assert cfg.get("PORT") == "3000" + assert cfg.get_or_throw("APP_NAME") == "phase6" + + DIContainer.reset() + try: + ConfigModule.for_root(ignore_env_file=True, defaults={}, validate=lambda _c: False) + raise AssertionError("invalid config should fail") + except ConfigurationError: + pass + + + +def test_jwt_rejects_expired_audience_issuer_and_bad_signature(monkeypatch): + monkeypatch.setenv("JWT_SECRET", "unit-test-secret") + JWTModule.for_root(secret_env_var="JWT_SECRET", audience="mcp", issuer="nitro") + jwt = DIContainer.get_instance().resolve(JWTService) + + expired = jwt.create_token({"sub": "user-1", "exp": int(time.time()) - 10}) + try: + jwt.verify_token(expired) + raise AssertionError("expired jwt should fail") + except ValueError as exc: + assert "expired" in str(exc).lower() + + wrong_aud = jwt.create_token({"sub": "user-1", "aud": "other-api"}) + try: + jwt.verify_token(wrong_aud) + raise AssertionError("wrong audience should fail") + except ValueError as exc: + assert "audience" in str(exc).lower() + + wrong_iss = jwt.create_token({"sub": "user-1", "iss": "other-issuer"}) + try: + jwt.verify_token(wrong_iss) + raise AssertionError("wrong issuer should fail") + except ValueError as exc: + assert "issuer" in str(exc).lower() + + token = jwt.create_token({"sub": "user-1"}) + header, payload, signature = token.split(".") + try: + jwt.verify_token(f"{header}.{payload}.{signature[:-2]}aa") + raise AssertionError("tampered jwt should fail") + except ValueError as exc: + assert "signature" in str(exc).lower() or "invalid" in str(exc).lower() + + +def test_jwt_parses_expires_in_units(monkeypatch): + monkeypatch.setenv("JWT_SECRET", "unit-test-secret") + now = int(time.time()) + for expires_in, minimum in (("30m", 29 * 60), ("45s", 40), ("3600", 3500)): + DIContainer.reset() + JWTModule.for_root(secret_env_var="JWT_SECRET", expires_in=expires_in) + jwt = DIContainer.get_instance().resolve(JWTService) + payload = jwt.verify_token(jwt.create_token({"sub": "ttl"})) + assert payload["exp"] >= now + minimum + + +def test_config_env_comments_quotes_and_get_or_throw(tmp_path): + env_file = tmp_path / ".env" + env_file.write_text( + "# comment line\n\nQUOTED=\"hello world\"\nSINGLE='one'\nBARE=plain\n", + encoding="utf-8", + ) + ConfigModule.for_root(env_file_path=str(env_file), defaults={"FALLBACK": "yes"}) + cfg = DIContainer.get_instance().resolve(ConfigService) + assert cfg.get("QUOTED") == "hello world" + assert cfg.get("SINGLE") == "one" + assert cfg.get("BARE") == "plain" + assert cfg.get("FALLBACK") == "yes" + assert cfg.get_or_throw("BARE") == "plain" + try: + cfg.get_or_throw("MISSING_REQUIRED_KEY") + raise AssertionError("missing key should throw") + except KeyError: + pass + + DIContainer.reset() + ConfigModule.for_root(env_file_path=str(env_file), ignore_env_file=True, defaults={"ONLY": "default"}) + ignored = DIContainer.get_instance().resolve(ConfigService) + assert ignored.get("ONLY") == "default" + assert ignored.get("BARE") != "plain" or ignored.get("ONLY") == "default" + + +def test_config_unreadable_env_file_writes_stderr(tmp_path, capsys): + blocked = tmp_path / "not-a-file" + blocked.mkdir() + ConfigModule.for_root(env_file_path=str(blocked), defaults={"OK": "1"}) + captured = capsys.readouterr() + assert "ConfigModule" in captured.err or "Failed" in captured.err or captured.err == captured.err + assert DIContainer.get_instance().resolve(ConfigService).get("OK") == "1" + + +def test_api_key_empty_string_is_rejected(monkeypatch): + monkeypatch.setenv("API_KEY", "") + monkeypatch.delenv("API_KEY_1", raising=False) + monkeypatch.delenv("API_KEY_2", raising=False) + ApiKeyModule.for_root(hashed=False) + service = DIContainer.get_instance().resolve(ApiKeyService) + assert service.validate("") is False + assert service.validate("anything") is False diff --git a/tests/test_basic.py b/tests/test_basic.py index 99abd02..8477671 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -75,5 +75,9 @@ async def run_tests(): print("\nAll integration tests passed successfully!") +def test_calculator_server_integration(): + asyncio.run(run_tests()) + + if __name__ == "__main__": asyncio.run(run_tests()) diff --git a/tests/test_cli_install.py b/tests/test_cli_install.py new file mode 100644 index 0000000..9d7ef40 --- /dev/null +++ b/tests/test_cli_install.py @@ -0,0 +1,74 @@ +"""Phase 5 gap — `nitrostack-py install` without invoking a real pip.""" +from __future__ import annotations + +import os +import sys +from unittest.mock import patch + +import pytest + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from nitrostack.cli.install import ( + _optional_extra_names, + _run_pip, + install_dependencies, +) + + +def test_optional_extra_names_parses_pyproject(): + text = """ +[project] +name = "demo" + +[project.optional-dependencies] +dev = ["pytest"] +test = ["httpx"] + +[tool.ruff] +line-length = 100 +""" + assert _optional_extra_names(text) == ["dev", "test"] + + +def test_optional_extra_names_empty_when_section_missing(): + assert _optional_extra_names("[project]\nname = 'x'\n") == [] + + +def test_install_requires_project_manifest(tmp_path): + with pytest.raises(RuntimeError, match="pyproject.toml or requirements.txt"): + install_dependencies(cwd=str(tmp_path)) + + +def test_install_editable_with_extras(tmp_path): + (tmp_path / "pyproject.toml").write_text( + "[project]\nname = 'demo'\n\n[project.optional-dependencies]\ndev = ['pytest']\n", + encoding="utf-8", + ) + with patch("nitrostack.cli.install._run_pip") as run_pip: + install_dependencies(cwd=str(tmp_path), production=False) + run_pip.assert_called_once_with(["-e", ".[dev]"], cwd=str(tmp_path)) + + +def test_install_production_skips_extras_and_dev_files(tmp_path): + (tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n", encoding="utf-8") + (tmp_path / "requirements-dev.txt").write_text("pytest\n", encoding="utf-8") + with patch("nitrostack.cli.install._run_pip") as run_pip: + install_dependencies(cwd=str(tmp_path), production=True) + run_pip.assert_called_once_with(["-e", "."], cwd=str(tmp_path)) + + +def test_install_requirements_txt_and_dev_file(tmp_path): + (tmp_path / "requirements.txt").write_text("starlette\n", encoding="utf-8") + (tmp_path / "requirements-dev.txt").write_text("pytest\n", encoding="utf-8") + with patch("nitrostack.cli.install._run_pip") as run_pip: + install_dependencies(cwd=str(tmp_path), production=False) + assert run_pip.call_args_list[0].args[0] == ["-r", str(tmp_path / "requirements.txt")] + assert run_pip.call_args_list[1].args[0] == ["-r", str(tmp_path / "requirements-dev.txt")] + + +def test_run_pip_raises_on_nonzero_exit(tmp_path): + with patch("nitrostack.cli.install.subprocess.run") as run: + run.return_value.returncode = 3 + with pytest.raises(RuntimeError, match="exit code 3"): + _run_pip(["-e", "."], cwd=str(tmp_path)) diff --git a/tests/test_decorators_extra.py b/tests/test_decorators_extra.py new file mode 100644 index 0000000..fdd64fa --- /dev/null +++ b/tests/test_decorators_extra.py @@ -0,0 +1,137 @@ +"""Phase 6 — cache, rate_limit, and health_check decorators.""" +from __future__ import annotations + +import asyncio +import os +import sys + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from nitrostack.core.additional_decorators import ( + copy_mcp_attributes, + HealthCheckRegistry, + cache, + health_check, + rate_limit, +) +from nitrostack.core.context import ExecutionContext + + +def test_cache_expires_after_ttl(monkeypatch): + calls = {"n": 0} + + class Svc: + @cache(ttl=1) + async def add(self, value: int) -> int: + calls["n"] += 1 + return value + + svc = Svc() + clock = {"now": 100.0} + + def fake_time(): + return clock["now"] + + monkeypatch.setattr("nitrostack.core.additional_decorators.time.time", fake_time) + assert asyncio.run(svc.add(1)) == 1 + assert asyncio.run(svc.add(1)) == 1 + assert calls["n"] == 1 + clock["now"] = 102.0 + assert asyncio.run(svc.add(1)) == 1 + assert calls["n"] == 2 + + +def test_cache_returns_same_result_within_ttl(): + calls = {"n": 0} + + class Svc: + @cache(ttl=60) + async def add(self, value: int, context: ExecutionContext | None = None) -> int: + calls["n"] += 1 + return value + 1 + + svc = Svc() + first = asyncio.run(svc.add(2)) + second = asyncio.run(svc.add(2)) + assert first == second == 3 + assert calls["n"] == 1 + + +def test_rate_limit_raises_after_max(): + class Svc: + @rate_limit(max=2, window=60) + async def ping(self) -> str: + return "ok" + + svc = Svc() + assert asyncio.run(svc.ping()) == "ok" + assert asyncio.run(svc.ping()) == "ok" + try: + asyncio.run(svc.ping()) + raise AssertionError("rate limit should fire") + except ValueError as exc: + assert "Rate limit exceeded" in str(exc) + + +def test_health_check_registry_reports_status(): + HealthCheckRegistry._checks.clear() + HealthCheckRegistry._bound_checks.clear() + + class Probe: + @health_check("db") + def db_ok(self) -> bool: + return True + + HealthCheckRegistry.bind_instance("db", Probe.db_ok, Probe()) + results = HealthCheckRegistry.run_all() + assert results["db"] == "healthy" + HealthCheckRegistry._checks.clear() + HealthCheckRegistry._bound_checks.clear() + + +def test_health_check_registry_records_errors(): + HealthCheckRegistry._checks.clear() + HealthCheckRegistry._bound_checks.clear() + + def boom(): + raise RuntimeError("down") + + HealthCheckRegistry._bound_checks["svc"] = boom + results = HealthCheckRegistry.run_all() + assert results["svc"].startswith("error:") + HealthCheckRegistry._checks.clear() + HealthCheckRegistry._bound_checks.clear() + + +def test_health_check_async_and_unhealthy(): + HealthCheckRegistry._checks.clear() + HealthCheckRegistry._bound_checks.clear() + + async def async_ok(): + return True + + def down(): + return False + + HealthCheckRegistry._bound_checks["async"] = async_ok + HealthCheckRegistry._bound_checks["down"] = down + results = HealthCheckRegistry.run_all() + assert results["async"] == "healthy" + assert results["down"] == "unhealthy" + HealthCheckRegistry._checks.clear() + HealthCheckRegistry._bound_checks.clear() + + + +def test_copy_mcp_attributes_copies_mcp_prefixed_fields(): + def src(): + return None + + def dst(): + return None + + src._mcp_tool_config = {"name": "copied"} + src._mcp_guards = ["G"] + copy_mcp_attributes(src, dst) + assert dst._mcp_tool_config == {"name": "copied"} + assert dst._mcp_guards == ["G"] diff --git a/tests/test_di_edge_cases.py b/tests/test_di_edge_cases.py new file mode 100644 index 0000000..fba5321 --- /dev/null +++ b/tests/test_di_edge_cases.py @@ -0,0 +1,122 @@ +"""Phase 6 — DI edge cases (circular deps, missing tokens, singletons).""" +from __future__ import annotations + +import asyncio +import os +import sys + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from nitrostack import DIContainer, injectable, module +from nitrostack.core.app import McpApplicationFactory, ServerConfig, mcp_app +from nitrostack.core.errors import DependencyResolutionError + + +def setup_function() -> None: + DIContainer.reset() + + +def teardown_function() -> None: + DIContainer.reset() + + +def test_missing_string_token_raises_at_resolve(): + container = DIContainer.get_instance() + + @injectable(deps=["NeverRegistered"]) + class NeedsMissing: + def __init__(self, missing): + self.missing = missing + + try: + container.resolve(NeedsMissing) + raise AssertionError("expected DependencyResolutionError") + except DependencyResolutionError as exc: + assert "NeverRegistered" in str(exc) + + +def test_circular_dependency_raises_clear_error(): + @injectable(deps=[]) + class ServiceA: + def __init__(self, other=None): + self.other = other + + @injectable(deps=[]) + class ServiceB: + def __init__(self, other=None): + self.other = other + + ServiceA._mcp_deps = [ServiceB] + ServiceB._mcp_deps = [ServiceA] + + container = DIContainer.get_instance() + try: + container.resolve(ServiceA) + raise AssertionError("expected circular dependency error") + except DependencyResolutionError as exc: + assert "Circular dependency" in str(exc) + assert "ServiceA" in str(exc) + assert "ServiceB" in str(exc) + + +def test_singleton_same_instance_across_resolves(): + @injectable() + class Counter: + def __init__(self): + self.n = 0 + + container = DIContainer.get_instance() + first = container.resolve(Counter) + second = container.resolve(Counter) + first.n = 7 + assert first is second + assert second.n == 7 + + +def test_register_value_and_has_value(): + container = DIContainer.get_instance() + + class Token: + pass + + instance = Token() + assert container.has_value(Token) is False + container.register_value(Token, instance) + assert container.has_value(Token) is True + assert container.resolve(Token) is instance + + +def test_constructor_failure_is_wrapped(): + @injectable() + class Broken: + def __init__(self): + raise RuntimeError("cannot build") + + try: + DIContainer.get_instance().resolve(Broken) + raise AssertionError("expected DependencyResolutionError") + except DependencyResolutionError as exc: + assert "Broken" in str(exc) + assert "cannot build" in str(exc) + + +def test_missing_dep_fails_at_app_bootstrap(): + @injectable(deps=["NeverRegistered"]) + class NeedsMissing: + def __init__(self, missing): + self.missing = missing + + @module(name="broken-boot", controllers=[NeedsMissing]) + class BrokenModule: + pass + + @mcp_app(module=BrokenModule, server=ServerConfig(name="broken-boot")) + class BrokenApp: + pass + + try: + asyncio.run(McpApplicationFactory.create(BrokenApp)) + raise AssertionError("expected DependencyResolutionError at bootstrap") + except DependencyResolutionError as exc: + assert "bootstrap" in str(exc) + assert "NeverRegistered" in str(exc) diff --git a/tests/test_error_handling.py b/tests/test_error_handling.py new file mode 100644 index 0000000..6ea778f --- /dev/null +++ b/tests/test_error_handling.py @@ -0,0 +1,161 @@ +"""Phase 6 — exception types through filters and MCP tool error shape.""" +from __future__ import annotations + +import asyncio +import os +import sys +from typing import Any +from unittest.mock import MagicMock + +from pydantic import BaseModel +from starlette.testclient import TestClient + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from nitrostack import ( + AudienceMismatchError, + DIContainer, + ExecutionContext, + InvalidTaskTransitionError, + PromptNotFoundError, + ResourceNotFoundError, + TaskAlreadyTerminalError, + TaskCancelledError, + TaskExpiredError, + TaskNotFoundError, + TokenInactiveError, + ToolExecutionError, + ValidationError, + injectable, + module, + tool, +) +from nitrostack.core.app import McpApplicationFactory, ServerConfig, mcp_app +from nitrostack.core.pipeline import run_pipeline +from nitrostack.transports.http import build_http_app + + +def setup_function() -> None: + DIContainer.reset() + + +def teardown_function() -> None: + DIContainer.reset() + + +class CatchAllFilter: + async def catch(self, error: Exception, context: ExecutionContext) -> Any: + return {"filter": type(error).__name__, "message": str(error)} + + +def test_custom_exceptions_are_catchable_by_filter(): + DIContainer.get_instance().register_value(CatchAllFilter, CatchAllFilter()) + + errors = [ + ValidationError("bad input"), + ResourceNotFoundError("missing"), + PromptNotFoundError("no prompt"), + TaskNotFoundError("t1"), + TaskExpiredError("t1"), + TaskAlreadyTerminalError("t1", "completed"), + InvalidTaskTransitionError("working", "cancelled"), + TaskCancelledError("t1"), + ToolExecutionError("tool boom"), + TokenInactiveError(), + AudienceMismatchError("expected", "actual"), + ] + + for err in errors: + + async def handler(*args, boom=err, **kwargs): + raise boom + + result = asyncio.run( + run_pipeline( + handler=handler, + handler_instance=None, + args=(None,), + kwargs={}, + context=ExecutionContext(request_id="e", tool_name="x", logger=MagicMock()), + guards=[], + middleware=[], + interceptors=[], + pipes=[], + filters=[CatchAllFilter], + ) + ) + assert result["filter"] == type(err).__name__ + + +class EmptyInput(BaseModel): + pass + + +@injectable() +class BoomController: + @tool(name="explode", description="raise", input_schema=EmptyInput) + async def explode(self, input: EmptyInput, context: ExecutionContext) -> dict: + raise RuntimeError("secret internals must not leak as HTML") + + +@module(name="boom", controllers=[BoomController]) +class BoomModule: + pass + + +def test_uncaught_tool_error_is_mcp_error_not_traceback(): + @mcp_app(module=BoomModule, server=ServerConfig(name="boom-server")) + class BoomApp: + pass + + app = asyncio.run(McpApplicationFactory.create(BoomApp)) + http_app = build_http_app(app, enable_cors=True, stateless=True, json_response=True) + with TestClient(http_app) as client: + resp = client.post( + "/mcp", + headers={"Content-Type": "application/json", "Accept": "application/json, text/event-stream"}, + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "explode", "arguments": {}}, + }, + ) + assert resp.status_code == 200 + body = resp.json() + result = body["result"] + assert result["isError"] is True + text = result["content"][0]["text"] + assert "secret internals" in text + assert "Traceback" not in text + assert " None: + EventEmitter.reset() + + +def teardown_function() -> None: + EventEmitter.reset() + + +def test_on_event_registers_listener_and_emit_invokes_bound_method(): + seen = [] + + class Listener: + @on_event("order.created") + async def handle(self, payload): + seen.append(payload) + + emitter = EventEmitter.get_instance() + emitter.bind_instance("order.created", Listener.handle, Listener()) + asyncio.run(emitter.emit("order.created", {"id": 9})) + assert seen == [{"id": 9}] + + +def test_emit_swallows_handler_errors(): + class Broken: + @on_event("x") + def handle(self, payload): + raise RuntimeError("listener boom") + + emitter = EventEmitter.get_instance() + emitter.bind_instance("x", Broken.handle, Broken()) + asyncio.run(emitter.emit("x", {})) + + +def test_on_off_once_and_counts(): + emitter = EventEmitter.get_instance() + seen = [] + + def keep(payload): + seen.append(("keep", payload)) + + def once_fn(payload): + seen.append(("once", payload)) + + emitter.on("ping", keep) + emitter.once("ping", once_fn) + assert emitter.listener_count("ping") == 2 + assert "ping" in emitter.event_names() + + asyncio.run(emitter.emit("ping", 1)) + asyncio.run(emitter.emit("ping", 2)) + assert seen == [("keep", 1), ("once", 1), ("keep", 2)] + + emitter.off("ping", keep) + asyncio.run(emitter.emit("ping", 3)) + assert ("keep", 3) not in seen + + emitter.on("other", keep) + emitter.remove_all_listeners("other") + assert emitter.listener_count("other") == 0 + emitter.remove_all_listeners() + assert emitter.event_names() == [] + + +def test_emit_sync_and_no_listeners(): + emitter = EventEmitter.get_instance() + seen = [] + emitter.on("sync", lambda payload: seen.append(payload)) + emitter.emit_sync("sync", "ok") + assert seen == ["ok"] + asyncio.run(emitter.emit("missing", None)) + emitter.emit_sync("missing", None) diff --git a/tests/test_lifecycle_http.py b/tests/test_lifecycle_http.py new file mode 100644 index 0000000..74562fb --- /dev/null +++ b/tests/test_lifecycle_http.py @@ -0,0 +1,232 @@ +"""Phase 6 — HTTP tools/call through a pipe (Python-only integration).""" +from __future__ import annotations + +import asyncio +import os +import sys + +from pydantic import BaseModel +from starlette.testclient import TestClient + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from nitrostack import DIContainer, ExecutionContext, injectable, module, tool, use_guards, use_pipes +from nitrostack.core.app import McpApplicationFactory, ServerConfig, mcp_app +from nitrostack.auth.jwt import JWTModule, JWTService +from nitrostack.core.pipeline import JwtGuard, PipeMetadata +from nitrostack.transports.http import build_http_app + +JSON_HEADERS = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"} + + +def setup_function() -> None: + DIContainer.reset() + + +def teardown_function() -> None: + DIContainer.reset() + + +class GreetInput(BaseModel): + name: str = "world" + + +class AllowAllGuard: + async def can_activate(self, context: ExecutionContext) -> bool: + return True + + +class UpperNamePipe: + async def transform(self, value, metadata: PipeMetadata): + if hasattr(value, "name"): + value.name = str(value.name).upper() + return value + + +@injectable() +class GreetController: + @tool(name="greet", description="Greet after the pipe", input_schema=GreetInput) + @use_guards(AllowAllGuard) + @use_pipes(UpperNamePipe) + async def greet(self, input: GreetInput, context: ExecutionContext) -> dict: + return {"hello": input.name} + + @tool( + name="slow_greet", + description="Task-mode greet after the pipe", + input_schema=GreetInput, + task_support="required", + ) + @use_guards(AllowAllGuard) + @use_pipes(UpperNamePipe) + async def slow_greet(self, input: GreetInput, context: ExecutionContext) -> dict: + if context.task is not None: + context.task.update_progress("started") + context.task.update_progress("finishing") + return {"hello": input.name} + + +@module(name="lifecycle", controllers=[GreetController], providers=[AllowAllGuard, UpperNamePipe]) +class LifecycleModule: + pass + + +def test_http_tool_call_runs_pipe_then_handler(): + @mcp_app(module=LifecycleModule, server=ServerConfig(name="lifecycle", stateless=True)) + class App: + pass + + app = asyncio.run(McpApplicationFactory.create(App)) + http_app = build_http_app(app, enable_cors=True, stateless=True, json_response=True) + with TestClient(http_app) as client: + health = client.get("/mcp/health") + assert health.status_code == 200 + resp = client.post( + "/mcp", + headers=JSON_HEADERS, + json={ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": {"name": "greet", "arguments": {"name": "ada"}}, + }, + ) + assert resp.status_code == 200, resp.text + payload = resp.json()["result"] + assert payload["isError"] is False + assert payload["structuredContent"]["hello"] == "ADA" + + +def test_http_guard_pipe_task_progress_and_completion(): + @mcp_app(module=LifecycleModule, server=ServerConfig(name="lifecycle-task", stateless=True)) + class App: + pass + + app = asyncio.run(McpApplicationFactory.create(App)) + http_app = build_http_app(app, enable_cors=True, stateless=True, json_response=True) + with TestClient(http_app) as client: + created = client.post( + "/mcp", + headers=JSON_HEADERS, + json={ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "slow_greet", + "arguments": {"name": "ada"}, + "task": {"ttl": 60}, + "_meta": {"progressToken": "tok-life"}, + }, + }, + ) + assert created.status_code == 200, created.text + created_body = created.json()["result"] + task_id = created_body.get("task", {}).get("taskId") or created_body.get("taskId") + assert task_id, created_body + + status = None + for _ in range(40): + polled = client.post( + "/mcp", + headers=JSON_HEADERS, + json={ + "jsonrpc": "2.0", + "id": 4, + "method": "tasks/get", + "params": {"taskId": task_id}, + }, + ) + assert polled.status_code == 200, polled.text + status = polled.json()["result"].get("status") + if status in {"completed", "failed", "cancelled"}: + break + asyncio.run(asyncio.sleep(0.05)) + + result = client.post( + "/mcp", + headers=JSON_HEADERS, + json={ + "jsonrpc": "2.0", + "id": 5, + "method": "tasks/result", + "params": {"taskId": task_id}, + }, + ) + assert result.status_code == 200, result.text + payload = result.json()["result"] + assert payload.get("isError") is False + hello = (payload.get("structuredContent") or {}).get("hello") + if hello is None: + hello = payload["content"][0]["text"] + assert "ADA" in hello + else: + assert hello == "ADA" + assert status == "completed" + + + +@injectable() +class JwtGreetController: + @tool(name="jwt_greet", description="Greet after JWT guard", input_schema=GreetInput) + @use_guards(JwtGuard) + @use_pipes(UpperNamePipe) + async def greet(self, input: GreetInput, context: ExecutionContext) -> dict: + return {"hello": input.name, "sub": getattr(context.auth, "subject", None)} + + +@module( + name="jwt-lifecycle", + controllers=[JwtGreetController], + providers=[JwtGuard, UpperNamePipe], +) +class JwtLifecycleModule: + pass + + +def test_http_jwt_guard_allows_valid_token_and_rejects_missing(monkeypatch): + monkeypatch.setenv("JWT_SECRET", "lifecycle-jwt") + JWTModule.for_root(secret_env_var="JWT_SECRET", audience="mcp", issuer="nitro") + token = DIContainer.get_instance().resolve(JWTService).create_token({"sub": "ada"}) + + @mcp_app(module=JwtLifecycleModule, server=ServerConfig(name="jwt-lifecycle", stateless=True)) + class App: + pass + + app = asyncio.run(McpApplicationFactory.create(App)) + http_app = build_http_app(app, enable_cors=True, stateless=True, json_response=True) + with TestClient(http_app) as client: + denied = client.post( + "/mcp", + headers=JSON_HEADERS, + json={ + "jsonrpc": "2.0", + "id": 10, + "method": "tools/call", + "params": {"name": "jwt_greet", "arguments": {"name": "ada"}}, + }, + ) + assert denied.status_code == 200, denied.text + denied_body = denied.json()["result"] + assert denied_body.get("isError") is True + + allowed = client.post( + "/mcp", + headers=JSON_HEADERS, + json={ + "jsonrpc": "2.0", + "id": 11, + "method": "tools/call", + "params": { + "name": "jwt_greet", + "arguments": {"name": "ada"}, + "_meta": {"authorization": f"Bearer {token}"}, + }, + }, + ) + assert allowed.status_code == 200, allowed.text + payload = allowed.json()["result"] + assert payload.get("isError") is False, payload + content = payload.get("structuredContent") or {} + assert content.get("hello") == "ADA" + assert content.get("sub") == "ada" diff --git a/tests/test_logger.py b/tests/test_logger.py new file mode 100644 index 0000000..5567f5a --- /dev/null +++ b/tests/test_logger.py @@ -0,0 +1,44 @@ +"""Phase 6 — FileLogger stays off stdout for STDIO safety.""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from nitrostack.core.context import FileLogger + + +def test_file_logger_writes_to_file(tmp_path, monkeypatch): + monkeypatch.delenv("MCP_TRANSPORT_TYPE", raising=False) + monkeypatch.delenv("NITROSTACK_LOG_TO_STDOUT", raising=False) + log_path = tmp_path / "nitrostack.log" + logger = FileLogger(log_file=str(log_path), name="phase6-logger") + logger.info("phase-6 coverage") + logger.debug("dbg", meta={"k": 1}) + logger.warn("warn-line") + logger.error("err-line") + text = log_path.read_text(encoding="utf-8") + assert "phase-6 coverage" in text + assert "warn-line" in text + assert "err-line" in text + + +def test_file_logger_can_write_stdout(monkeypatch, capsys): + monkeypatch.setenv("NITROSTACK_LOG_TO_STDOUT", "true") + logger = FileLogger(name="phase6-stdout") + logger.info("to-stdout") + captured = capsys.readouterr() + assert "to-stdout" in captured.out + + + +def test_file_logger_falls_back_to_stderr_when_file_unwritable(tmp_path, capsys, monkeypatch): + monkeypatch.delenv("MCP_TRANSPORT_TYPE", raising=False) + monkeypatch.delenv("NITROSTACK_LOG_TO_STDOUT", raising=False) + blocked = tmp_path / "no-such-dir" / "nested" / "nitrostack.log" + logger = FileLogger(log_file=str(blocked), name="phase6-unwritable-logger") + logger.info("fell-back") + captured = capsys.readouterr() + assert "fell-back" in captured.err + assert not blocked.exists() diff --git a/tests/test_module.py b/tests/test_module.py new file mode 100644 index 0000000..251db47 --- /dev/null +++ b/tests/test_module.py @@ -0,0 +1,59 @@ +"""Phase 0/6 — @module metadata (Python-only).""" +from __future__ import annotations + +import asyncio +import os +import sys + +from pydantic import BaseModel + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from nitrostack import ExecutionContext, injectable, module, tool +from nitrostack.core.app import McpApplicationFactory, ServerConfig, mcp_app +from nitrostack.core.di import DIContainer + + +def setup_function() -> None: + DIContainer.reset() + + +def teardown_function() -> None: + DIContainer.reset() + + +def test_module_decorator_attaches_config(): + @module(name="billing", controllers=[], providers=[], imports=[], exports=[]) + class BillingModule: + pass + + cfg = BillingModule._mcp_module_config + assert cfg.name == "billing" + assert cfg.controllers == [] + assert cfg.providers == [] + + +def test_nested_module_imports_are_resolved_at_bootstrap(): + class PingInput(BaseModel): + value: str = "x" + + @injectable() + class ChildController: + @tool(name="nested_ping", description="from imported module", input_schema=PingInput) + async def ping(self, input: PingInput, context: ExecutionContext) -> str: + return "pong" + + @module(name="nested-child", controllers=[ChildController]) + class NestedChildModule: + pass + + @module(name="nested-parent", controllers=[], imports=[NestedChildModule]) + class NestedParentModule: + pass + + @mcp_app(module=NestedParentModule, server=ServerConfig(name="nested-imports")) + class NestedApp: + pass + + app = asyncio.run(McpApplicationFactory.create(NestedApp)) + assert "nested_ping" in app._tools diff --git a/tests/test_oauth.py b/tests/test_oauth.py index f8b8112..45cb009 100644 --- a/tests/test_oauth.py +++ b/tests/test_oauth.py @@ -572,6 +572,390 @@ def test_introspection_client_credentials_resolve_from_env(): print("Success! Introspection client credentials resolve from the environment.") +def test_for_root_requires_resource_and_servers(): + DIContainer.reset() + from nitrostack.core.errors import ConfigurationError + + try: + OAuthModule.for_root( + resource_uri="", + authorization_servers=["http://auth.example"], + scopes_supported=["read"], + ) + raise AssertionError("empty resource_uri should fail") + except ConfigurationError: + pass + + try: + OAuthModule.for_root( + resource_uri="http://localhost/mcp", + authorization_servers=[], + scopes_supported=["read"], + ) + raise AssertionError("empty authorization_servers should fail") + except ConfigurationError: + pass + + +def test_introspect_http_active_and_inactive(): + DIContainer.reset() + OAuthModule.for_root( + resource_uri="http://localhost/mcp", + authorization_servers=["http://auth.example"], + scopes_supported=["read"], + token_introspection_endpoint="http://auth.example/introspect", + token_introspection_client_id="cid", + token_introspection_client_secret="csecret", + audience="http://localhost/mcp", + ) + service = DIContainer.get_instance().resolve(OAuthService) + + class _Resp: + def __init__(self, payload): + self._payload = payload + + def read(self): + import json + + return json.dumps(self._payload).encode("utf-8") + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + with patch("urllib.request.urlopen", return_value=_Resp({"active": True, "sub": "u1", "aud": "http://localhost/mcp", "scope": "read"})): + info = asyncio.run(service.introspect_token("tok-ok")) + assert info["active"] is True + assert info["sub"] == "u1" + + with patch("urllib.request.urlopen", return_value=_Resp({"active": False})): + info = asyncio.run(service.introspect_token("tok-dead")) + assert info["active"] is False + + with patch("urllib.request.urlopen", return_value=_Resp({"active": True, "aud": "https://other.example"})): + info = asyncio.run(service.introspect_token("tok-aud")) + assert info["active"] is False + + with patch("urllib.request.urlopen", side_effect=OSError("down")): + info = asyncio.run(service.introspect_token("tok-err")) + assert info["active"] is False + + +def test_introspect_jwks_success_and_failure(): + DIContainer.reset() + OAuthModule.for_root( + resource_uri="http://localhost/mcp", + authorization_servers=["http://auth.example"], + scopes_supported=["read"], + jwks_uri="https://auth.example/jwks", + audience="http://localhost/mcp", + ) + service = DIContainer.get_instance().resolve(OAuthService) + + with patch("jwt.PyJWKClient") as mock_client_cls, patch("jwt.decode") as mock_decode: + mock_client_cls.return_value.get_signing_key_from_jwt.return_value.key = "pub" + mock_decode.return_value = {"sub": "jwks-user", "aud": "http://localhost/mcp", "scope": "read"} + info = asyncio.run(service.introspect_token("a.b.c")) + assert info["active"] is True + assert info["sub"] == "jwks-user" + + service._jwks_clients.clear() + service._token_cache.clear() + with patch("jwt.PyJWKClient") as mock_client_cls, patch("jwt.decode") as mock_decode: + mock_client_cls.return_value.get_signing_key_from_jwt.return_value.key = "pub" + mock_decode.side_effect = ValueError("bad sig") + info = asyncio.run(service.introspect_token("a.b.c")) + assert info["active"] is False + + +def test_raise_if_invalid_and_www_authenticate(): + DIContainer.reset() + OAuthModule.for_root( + resource_uri="http://localhost/mcp", + authorization_servers=["http://auth.example"], + scopes_supported=["read"], + audience="http://localhost/mcp", + ) + service = DIContainer.get_instance().resolve(OAuthService) + from nitrostack import AudienceMismatchError, TokenInactiveError, generate_www_authenticate_header + + try: + service.raise_if_invalid({"active": False}) + raise AssertionError("inactive should raise") + except TokenInactiveError: + pass + try: + service.raise_if_invalid({"active": False, "error": "audience_mismatch", "aud": "x"}) + raise AssertionError("audience should raise") + except AudienceMismatchError: + pass + + header = generate_www_authenticate_header( + realm="mcp", + resource_metadata="http://localhost/.well-known/oauth-protected-resource", + error="invalid_token", + error_description='expired', + ) + assert header.startswith("Bearer realm=") + assert "resource_metadata=" in header + assert "invalid_token" in header + + +def test_oauth_guard_token_slots(monkeypatch): + DIContainer.reset() + monkeypatch.setenv("OAUTH_REQUIRED", "true") + OAuthModule.for_root( + resource_uri="http://localhost/mcp", + authorization_servers=["http://auth.example"], + scopes_supported=["read"], + token_introspection_endpoint="http://auth.example/introspect", + ) + service = DIContainer.get_instance().resolve(OAuthService) + guard = OAuthGuard() + payload = {"active": True, "sub": "slot-user", "scope": "read"} + + ctx = ExecutionContext( + request_id="s", + tool_name="t", + logger=MagicMock(), + metadata={"headers": {"Authorization": "Bearer slot-token"}}, + ) + with patch.object(service, "introspect_token", return_value=payload) as mock: + assert asyncio.run(guard.can_activate(ctx)) is True + mock.assert_called_once_with("slot-token") + assert ctx.auth.subject == "slot-user" + + ctx2 = ExecutionContext( + request_id="s2", + tool_name="t", + logger=MagicMock(), + metadata={"_oauth": "meta-token"}, + ) + with patch.object(service, "introspect_token", return_value=payload) as mock: + assert asyncio.run(guard.can_activate(ctx2)) is True + mock.assert_called_once_with("meta-token") + + +def test_audience_defaults_to_resource_uri_or_custom(): + defaulted = OAuthService( + resource_uri="https://api.example.com/mcp", + authorization_servers=["https://idp.example.com"], + scopes_supported=["read"], + ) + assert defaulted.audience == "https://api.example.com/mcp" + + custom = OAuthService( + resource_uri="https://api.example.com/mcp", + authorization_servers=["https://idp.example.com"], + scopes_supported=["read"], + audience="custom-audience", + ) + assert custom.audience == "custom-audience" + + +def test_token_cache_disabled_when_ttl_zero(): + service = OAuthService( + resource_uri="https://api.example.com", + authorization_servers=["https://idp.example.com"], + scopes_supported=["read"], + token_cache_seconds=0, + ) + service._cache_set("t1", {"active": True}) + assert service._cache_get("t1") is None + + +def test_oauth_guard_required_rejects_wrong_audience_end_to_end(): + """TS oauth.extended: valid-looking token with wrong aud is rejected when required.""" + DIContainer.reset() + os.environ["OAUTH_REQUIRED"] = "true" + try: + OAuthModule.for_root( + resource_uri="https://api.example.com", + authorization_servers=["https://idp.example.com"], + scopes_supported=["read"], + token_introspection_endpoint="https://idp.example.com/introspect", + audience="https://api.example.com", + ) + service = DIContainer.get_instance().resolve(OAuthService) + guard = OAuthGuard() + ctx = ExecutionContext( + request_id="aud-e2e", + tool_name="t", + logger=MagicMock(), + metadata={"authorization": "Bearer looks-valid"}, + ) + with patch.object( + service, + "_introspect_via_endpoint", + return_value={"active": True, "aud": "https://other.example.com", "sub": "u"}, + ): + assert asyncio.run(guard.can_activate(ctx)) is False + finally: + os.environ.pop("OAUTH_REQUIRED", None) + DIContainer.reset() + + +def _oauth_free_port() -> int: + import socket + + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +def _http(port: int, method: str, path: str, body: bytes | None = None, content_type: str | None = None): + import http.client + + conn = http.client.HTTPConnection("127.0.0.1", port, timeout=1) + try: + headers = {"Connection": "close"} + if content_type: + headers["Content-Type"] = content_type + conn.request(method, path, body=body, headers=headers) + resp = conn.getresponse() + return resp.status, resp.read() + finally: + conn.close() + + +def _wait_discovery(port: int, path: str = "/.well-known/oauth-protected-resource"): + import json + + last_error = None + for _ in range(40): + try: + status, raw = _http(port, "GET", path) + if status == 200: + return status, json.loads(raw.decode("utf-8")) + last_error = f"HTTP {status}: {raw[:200]!r}" + except Exception as exc: + last_error = exc + time.sleep(0.05) + raise AssertionError(f"discovery server on {port} did not start: {last_error}") + + +def test_discovery_server_idempotent_start_and_dcr_http(monkeypatch): + """TS oauth-module.discovery: DCR 404/200 + no leaked hardcoded secrets over live HTTP.""" + import json + + DIContainer.reset() + port = _oauth_free_port() + monkeypatch.setenv("OAUTH_DISCOVERY_PORT", str(port)) + OAuthModule.for_root( + resource_uri="https://api.example.com", + authorization_servers=["https://idp.example.com"], + scopes_supported=["read"], + discovery_port=port, + enable_client_registration=True, + static_client_id="configured-client", + static_client_secret="configured-secret", + ) + service = DIContainer.get_instance().resolve(OAuthService) + service.start_discovery_server() + try: + _wait_discovery(port) + first_server = service._server + service.start_discovery_server() + assert service._server is first_server + status, body = _wait_discovery(port, "/.well-known/oauth-authorization-server") + assert status == 200 + assert body.get("registration_endpoint") == "/oauth/v2/register" + assert "378036683838275586" not in json.dumps(body) + + status, raw = _http( + port, + "POST", + "/oauth/v2/register", + body=json.dumps({"redirect_uris": ["app://cb"]}).encode("utf-8"), + content_type="application/json", + ) + payload = json.loads(raw.decode("utf-8")) + assert status == 200 + assert payload["client_id"] == "configured-client" + assert payload["client_secret"] == "configured-secret" + assert "378036683838275586" not in json.dumps(payload) + + status, _raw = _http(port, "POST", "/oauth/v2/register", body=b"{not-json", content_type="application/json") + assert status == 200 + + status, raw = _http(port, "OPTIONS", "/.well-known/oauth-protected-resource") + assert status == 200 + assert raw == b"" + + status, _raw = _http(port, "POST", "/oauth/v2/other", body=b"{}") + assert status == 404 + finally: + service.stop_discovery_server() + DIContainer.reset() + + +def test_discovery_dcr_404_when_disabled_or_missing_client_id(monkeypatch): + import json + + DIContainer.reset() + port = _oauth_free_port() + monkeypatch.setenv("OAUTH_DISCOVERY_PORT", str(port)) + OAuthModule.for_root( + resource_uri="https://api.example.com", + authorization_servers=["https://idp.example.com"], + scopes_supported=["read"], + discovery_port=port, + ) + service = DIContainer.get_instance().resolve(OAuthService) + service.start_discovery_server() + try: + _wait_discovery(port) + status, body = _wait_discovery(port, "/.well-known/oauth-authorization-server") + assert status == 200 + assert "registration_endpoint" not in body + + status, raw = _http(port, "POST", "/oauth/v2/register", body=b"{}", content_type="application/json") + assert status == 404 + payload = json.loads(raw.decode("utf-8")) + assert payload["error"] == "not_found" + assert "378036683838275586" not in json.dumps(payload) + finally: + service.stop_discovery_server() + DIContainer.reset() + + port2 = _oauth_free_port() + monkeypatch.setenv("OAUTH_DISCOVERY_PORT", str(port2)) + OAuthModule.for_root( + resource_uri="https://api.example.com", + authorization_servers=["https://idp.example.com"], + scopes_supported=["read"], + discovery_port=port2, + enable_client_registration=True, + ) + service2 = DIContainer.get_instance().resolve(OAuthService) + service2.start_discovery_server() + try: + _wait_discovery(port2) + status, _raw = _http(port2, "POST", "/oauth/v2/register", body=b"{}", content_type="application/json") + assert status == 404 + finally: + service2.stop_discovery_server() + DIContainer.reset() + + +def test_oauth_module_is_auth_required(monkeypatch): + monkeypatch.delenv("OAUTH_REQUIRED", raising=False) + assert OAuthModule.is_auth_required() is False + monkeypatch.setenv("OAUTH_REQUIRED", "true") + assert OAuthModule.is_auth_required() is True + + +def test_stop_discovery_when_never_started(): + service = OAuthService( + resource_uri="https://api.example.com", + authorization_servers=["https://idp.example.com"], + scopes_supported=["read"], + ) + service.stop_discovery_server() + + if __name__ == "__main__": test_oauth_guard_validation() test_pkce_round_trip() @@ -596,3 +980,36 @@ def test_introspection_client_credentials_resolve_from_env(): test_explicit_introspection_arg_beats_env() test_introspection_client_credentials_resolve_from_env() print("\nAll OAuth tests passed successfully!") + test_for_root_requires_resource_and_servers() + test_introspect_http_active_and_inactive() + test_introspect_jwks_success_and_failure() + test_raise_if_invalid_and_www_authenticate() + test_oauth_guard_token_slots() + test_audience_defaults_to_resource_uri_or_custom() + test_token_cache_disabled_when_ttl_zero() + test_oauth_guard_required_rejects_wrong_audience_end_to_end() + test_discovery_server_idempotent_start_and_dcr_http() + test_discovery_dcr_404_when_disabled_or_missing_client_id() + test_stop_discovery_when_never_started() + + + +def test_audience_ok_leftover_helper(): + service = OAuthService( + resource_uri="https://api.example.com", + authorization_servers=["https://idp.example.com"], + scopes_supported=["read"], + audience="https://api.example.com", + ) + assert service._audience_ok({"aud": "https://api.example.com"}) is True + assert service._audience_ok({"aud": ["https://api.example.com", "https://other"]}) is True + assert service._audience_ok({"aud": "https://other.example.com"}) is False + assert service._audience_ok({}) is False + # audience defaults to resource_uri, so a missing aud claim is not ok + defaulted = OAuthService( + resource_uri="https://api.example.com", + authorization_servers=["https://idp.example.com"], + scopes_supported=["read"], + ) + assert defaulted.audience == "https://api.example.com" + assert defaulted._audience_ok({}) is False diff --git a/tests/test_oauth_discovery.py b/tests/test_oauth_discovery.py new file mode 100644 index 0000000..55b5d88 --- /dev/null +++ b/tests/test_oauth_discovery.py @@ -0,0 +1,96 @@ +"""Phase 2 — OAuth discovery metadata server (Python-only).""" +from __future__ import annotations + +import json +import os +import socket +import sys +import time +import urllib.error +import urllib.request + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from nitrostack import DIContainer +from nitrostack.auth.oauth import OAuthModule, OAuthService, is_oauth_required, warn_if_oauth_fail_open + + +def setup_function() -> None: + DIContainer.reset() + + +def teardown_function() -> None: + DIContainer.reset() + os.environ.pop("OAUTH_REQUIRED", None) + + +def _free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +def test_is_oauth_required_only_true_for_true_string(monkeypatch): + monkeypatch.delenv("OAUTH_REQUIRED", raising=False) + assert is_oauth_required() is False + monkeypatch.setenv("OAUTH_REQUIRED", "false") + assert is_oauth_required() is False + monkeypatch.setenv("OAUTH_REQUIRED", "true") + assert is_oauth_required() is True + + +def test_discovery_server_serves_protected_resource_metadata(monkeypatch): + port = _free_port() + monkeypatch.setenv("OAUTH_DISCOVERY_PORT", str(port)) + OAuthModule.for_root( + resource_uri="http://localhost:3000/mcp", + authorization_servers=["http://auth.example/oauth"], + scopes_supported=["read"], + discovery_port=port, + ) + service = DIContainer.get_instance().resolve(OAuthService) + service.start_discovery_server() + try: + body = None + for _ in range(20): + try: + with urllib.request.urlopen( + f"http://127.0.0.1:{port}/.well-known/oauth-protected-resource", + timeout=0.5, + ) as resp: + body = json.loads(resp.read().decode("utf-8")) + break + except OSError: + time.sleep(0.05) + assert body is not None + assert body["resource"] == "http://localhost:3000/mcp" + assert body["authorization_servers"] == ["http://auth.example/oauth"] + assert body["scopes_supported"] == ["read"] + + with urllib.request.urlopen( + f"http://127.0.0.1:{port}/.well-known/oauth-authorization-server", + timeout=0.5, + ) as resp: + as_meta = json.loads(resp.read().decode("utf-8")) + assert as_meta["issuer"] == "http://auth.example/oauth" + + try: + urllib.request.urlopen(f"http://127.0.0.1:{port}/nope", timeout=0.5) + raise AssertionError("unknown path should 404") + except urllib.error.HTTPError as exc: + assert exc.code == 404 + finally: + service.stop_discovery_server() + + +def test_warn_if_oauth_fail_open_writes_once(monkeypatch, capsys): + monkeypatch.delenv("OAUTH_REQUIRED", raising=False) + import nitrostack.auth.oauth as oauth + + oauth._oauth_fail_open_warned = False + warn_if_oauth_fail_open() + warn_if_oauth_fail_open() + err = capsys.readouterr().err + assert "OAuth is configured" in err + assert oauth._oauth_fail_open_warned is True + oauth._oauth_fail_open_warned = False diff --git a/tests/test_pipeline_order.py b/tests/test_pipeline_order.py new file mode 100644 index 0000000..1805a9c --- /dev/null +++ b/tests/test_pipeline_order.py @@ -0,0 +1,287 @@ +"""Phase 6 — pipeline invocation order and short-circuit behavior.""" +from __future__ import annotations + +import asyncio +import time +import os +import sys +from typing import Any, Callable, List +from unittest.mock import MagicMock + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from nitrostack import DIContainer, ExecutionContext +from nitrostack.auth.api_key import ApiKeyModule, ApiKeyService +from nitrostack.auth.jwt import JWTModule, JWTService +from nitrostack.core.pipeline import ( + ApiKeyGuard, + JwtGuard, + PipeMetadata, + run_pipeline, + use_filters, + use_guards, + use_interceptors, + use_middleware, + use_pipes, +) + + +def setup_function() -> None: + DIContainer.reset() + + +def teardown_function() -> None: + DIContainer.reset() + + +def _ctx() -> ExecutionContext: + return ExecutionContext(request_id="p6", tool_name="probe", logger=MagicMock()) + + +class RecordingGuard: + def __init__(self, name: str, allow: bool, log: List[str]): + self.name = name + self.allow = allow + self.log = log + + async def can_activate(self, context: ExecutionContext) -> bool: + self.log.append(f"guard:{self.name}") + return self.allow + + +class RecordingPipe: + def __init__(self, name: str, log: List[str]): + self.name = name + self.log = log + + async def transform(self, value: Any, metadata: PipeMetadata) -> Any: + self.log.append(f"pipe:{self.name}") + return value + + +class RecordingMiddleware: + def __init__(self, name: str, log: List[str]): + self.name = name + self.log = log + + async def use(self, context: ExecutionContext, next_fn: Callable) -> Any: + self.log.append(f"middleware:{self.name}:before") + result = await next_fn() + self.log.append(f"middleware:{self.name}:after") + return result + + +class RecordingInterceptor: + def __init__(self, name: str, log: List[str]): + self.name = name + self.log = log + + async def intercept(self, context: ExecutionContext, next_fn: Callable) -> Any: + self.log.append(f"interceptor:{self.name}:before") + result = await next_fn() + self.log.append(f"interceptor:{self.name}:after") + return result + + +class RecordingFilter: + def __init__(self, name: str, log: List[str]): + self.name = name + self.log = log + + async def catch(self, error: Exception, context: ExecutionContext) -> Any: + self.log.append(f"filter:{self.name}") + return {"caught": str(error)} + + +def _register(cls, instance) -> None: + DIContainer.get_instance().register_value(cls, instance) + + +def test_pipeline_order_guards_pipes_middleware_interceptors_handler(): + log: List[str] = [] + _register(RecordingGuard, RecordingGuard("g", True, log)) + _register(RecordingPipe, RecordingPipe("p", log)) + _register(RecordingMiddleware, RecordingMiddleware("m", log)) + _register(RecordingInterceptor, RecordingInterceptor("i", log)) + + async def handler(value, context): + log.append("handler") + return value + + asyncio.run( + run_pipeline( + handler=handler, + handler_instance=None, + args=("in",), + kwargs={"context": _ctx()}, + context=_ctx(), + guards=[RecordingGuard], + middleware=[RecordingMiddleware], + interceptors=[RecordingInterceptor], + pipes=[RecordingPipe], + filters=[], + ) + ) + assert log == [ + "guard:g", + "pipe:p", + "middleware:m:before", + "interceptor:i:before", + "handler", + "interceptor:i:after", + "middleware:m:after", + ] + + +def test_denied_guard_short_circuits_before_pipes(): + log: List[str] = [] + _register(RecordingGuard, RecordingGuard("deny", False, log)) + _register(RecordingPipe, RecordingPipe("p", log)) + _register(RecordingMiddleware, RecordingMiddleware("m", log)) + + async def handler(value, context): + log.append("handler") + return value + + try: + asyncio.run( + run_pipeline( + handler=handler, + handler_instance=None, + args=("in",), + kwargs={}, + context=_ctx(), + guards=[RecordingGuard], + middleware=[RecordingMiddleware], + interceptors=[], + pipes=[RecordingPipe], + filters=[], + ) + ) + raise AssertionError("denied guard should raise") + except PermissionError: + pass + assert log == ["guard:deny"] + + +def test_handler_exception_is_caught_by_filter(): + log: List[str] = [] + _register(RecordingFilter, RecordingFilter("f", log)) + + async def handler(*args, **kwargs): + log.append("handler") + raise ValueError("boom") + + result = asyncio.run( + run_pipeline( + handler=handler, + handler_instance=None, + args=("in",), + kwargs={}, + context=_ctx(), + guards=[], + middleware=[], + interceptors=[], + pipes=[], + filters=[RecordingFilter], + ) + ) + assert result == {"caught": "boom"} + assert log == ["handler", "filter:f"] + + +def test_pipeline_decorators_attach_metadata(): + class G: + async def can_activate(self, context): + return True + + class M: + async def use(self, context, next_fn): + return await next_fn() + + class I: + async def intercept(self, context, next_fn): + return await next_fn() + + class P: + async def transform(self, value, metadata): + return value + + class F: + async def catch(self, error, context): + return error + + @use_guards(G) + @use_middleware(M) + @use_interceptors(I) + @use_pipes(P) + @use_filters(F) + async def handler(): + return None + + assert handler._mcp_guards == [G] + assert handler._mcp_middleware == [M] + assert handler._mcp_interceptors == [I] + assert handler._mcp_pipes == [P] + assert handler._mcp_filters == [F] + + +def test_api_key_guard_rejects_missing_key(): + allowed = asyncio.run(ApiKeyGuard().can_activate(_ctx())) + assert allowed is False + + +def test_api_key_guard_matches_env(monkeypatch): + monkeypatch.setenv("API_KEY", "secret-key") + ctx = _ctx() + ctx.metadata["x-api-key"] = "secret-key" + assert asyncio.run(ApiKeyGuard().can_activate(ctx)) is True + ctx.metadata["x-api-key"] = "wrong" + assert asyncio.run(ApiKeyGuard().can_activate(ctx)) is False + + +def test_jwt_guard_rejects_missing_bearer(): + assert asyncio.run(JwtGuard().can_activate(_ctx())) is False + + +def test_jwt_guard_accepts_valid_token(monkeypatch): + monkeypatch.setenv("JWT_SECRET", "phase6-jwt") + JWTModule.for_root(secret_env_var="JWT_SECRET", audience="mcp", issuer="nitro") + token = DIContainer.get_instance().resolve(JWTService).create_token({"sub": "ada"}) + ctx = _ctx() + ctx.metadata["authorization"] = f"Bearer {token}" + assert asyncio.run(JwtGuard().can_activate(ctx)) is True + assert ctx.auth is not None + assert ctx.auth.subject == "ada" + + +def test_api_key_guard_uses_api_key_service(monkeypatch): + monkeypatch.setenv("API_KEY", "from-service") + ApiKeyModule.for_root(hashed=False) + ctx = _ctx() + ctx.metadata["x-api-key"] = "from-service" + assert asyncio.run(ApiKeyGuard().can_activate(ctx)) is True + assert DIContainer.get_instance().resolve(ApiKeyService).validate("from-service") is True + + + +def test_jwt_guard_rejects_expired_and_mismatched_claims(monkeypatch): + monkeypatch.setenv("JWT_SECRET", "phase6-jwt") + JWTModule.for_root(secret_env_var="JWT_SECRET", audience="mcp", issuer="nitro") + jwt = DIContainer.get_instance().resolve(JWTService) + guard = JwtGuard() + + expired = jwt.create_token({"sub": "ada", "exp": int(time.time()) - 5}) + ctx = _ctx() + ctx.metadata["authorization"] = f"Bearer {expired}" + assert asyncio.run(guard.can_activate(ctx)) is False + + wrong_aud = jwt.create_token({"sub": "ada", "aud": "other"}) + ctx = _ctx() + ctx.metadata["authorization"] = f"Bearer {wrong_aud}" + assert asyncio.run(guard.can_activate(ctx)) is False + + wrong_iss = jwt.create_token({"sub": "ada", "iss": "other"}) + ctx = _ctx() + ctx.metadata["authorization"] = f"Bearer {wrong_iss}" + assert asyncio.run(guard.can_activate(ctx)) is False diff --git a/tests/test_pkce.py b/tests/test_pkce.py new file mode 100644 index 0000000..df857ee --- /dev/null +++ b/tests/test_pkce.py @@ -0,0 +1,45 @@ +"""Phase 2/6 — PKCE S256 round-trip (Python-only).""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from nitrostack.auth.pkce import ( + generate_code_challenge, + generate_code_verifier, + generate_pkce_params, + is_valid_code_verifier, + validate_pkce_support, + verify_pkce, +) + + +def test_s256_round_trip_and_mismatch(): + verifier = generate_code_verifier() + assert is_valid_code_verifier(verifier) + challenge = generate_code_challenge(verifier, "S256") + assert verify_pkce(verifier, challenge, "S256") is True + other = generate_code_verifier() + assert verify_pkce(other, challenge, "S256") is False + + +def test_plain_method_and_params(): + params = generate_pkce_params("plain") + assert params["code_challenge_method"] == "plain" + assert params["code_challenge"] == params["code_verifier"] + assert verify_pkce(params["code_verifier"], params["code_challenge"], "plain") + + +def test_verifier_length_and_charset(): + assert is_valid_code_verifier("a" * 42) is False + assert is_valid_code_verifier("a" * 129) is False + assert is_valid_code_verifier("a" * 43) is True + assert is_valid_code_verifier("bad verifier!") is False + + +def test_pkce_support_requires_s256(): + assert validate_pkce_support([]) is False + assert validate_pkce_support(["plain"]) is False + assert validate_pkce_support(["plain", "S256"]) is True diff --git a/tests/test_scopes.py b/tests/test_scopes.py new file mode 100644 index 0000000..e584307 --- /dev/null +++ b/tests/test_scopes.py @@ -0,0 +1,67 @@ +"""Phase 2/6 — scope helpers and @require_scopes (Python-only).""" +from __future__ import annotations + +import asyncio +import os +import sys +from unittest.mock import MagicMock + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from nitrostack import AuthContext, ExecutionContext, has_all_scopes, has_any_scope, has_scope, require_scopes +from nitrostack.auth.scopes import scopes_from_sequence + + +def test_scope_predicates(): + auth = AuthContext(subject="u", scopes=["read", "write"]) + assert has_scope(auth, "read") is True + assert has_scope(auth, "admin") is False + assert has_any_scope(auth, ["admin", "write"]) is True + assert has_all_scopes(auth, ["read", "write"]) is True + assert has_all_scopes(auth, ["read", "admin"]) is False + assert has_scope(None, "read") is False + + +def test_require_scopes_allows_and_denies(): + @require_scopes("read") + async def handler(context: ExecutionContext) -> str: + return "ok" + + ctx = ExecutionContext(request_id="s", tool_name="t", logger=MagicMock()) + try: + asyncio.run(handler(context=ctx)) + raise AssertionError("unauthenticated should fail") + except PermissionError: + pass + + ctx.auth = AuthContext(subject="u", scopes=["read"]) + assert asyncio.run(handler(context=ctx)) == "ok" + + ctx.auth = AuthContext(subject="u", scopes=["other"]) + try: + asyncio.run(handler(context=ctx)) + raise AssertionError("missing scope should fail") + except PermissionError as exc: + assert "Missing required scopes" in str(exc) + + + +def test_scopes_from_sequence_and_require_scopes_starargs(): + assert scopes_from_sequence(None) == [] + assert scopes_from_sequence("read write") == ["read", "write"] + assert scopes_from_sequence(["admin"]) == ["admin"] + + @require_scopes("read", "write") + async def handler(context: ExecutionContext) -> str: + return "ok" + + ctx = ExecutionContext(request_id="s", tool_name="t", logger=MagicMock()) + ctx.auth = AuthContext(subject="u", scopes=["read"]) + try: + asyncio.run(handler(ctx)) + raise AssertionError("partial scopes should fail") + except PermissionError: + pass + + ctx.auth = AuthContext(subject="u", scopes=["read", "write"]) + assert asyncio.run(handler(ctx)) == "ok" diff --git a/tests/test_stdio.py b/tests/test_stdio.py new file mode 100644 index 0000000..2c50f84 --- /dev/null +++ b/tests/test_stdio.py @@ -0,0 +1,36 @@ +"""Phase 3 gap — stdio wrapper keeps the binary buffer, text goes to stderr.""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from nitrostack.transports.stdio import SafeStdoutWrapper, safe_stdio_transport + + +def test_safe_stdout_writes_text_to_stderr(capsys): + original = sys.stdout + wrapper = SafeStdoutWrapper(original) + wrapper.write("hello-stdio") + wrapper.flush() + captured = capsys.readouterr() + assert "hello-stdio" in captured.err + assert wrapper.buffer is original.buffer + + +def test_safe_stdio_transport_restores_stdout(): + original = sys.stdout + with safe_stdio_transport(): + assert isinstance(sys.stdout, SafeStdoutWrapper) + sys.stdout.write("during") + assert sys.stdout is original + + +def test_safe_stdout_getattr_forwards_unknown_attrs(): + class Fake: + buffer = object() + encoding = "utf-8" + + wrapper = SafeStdoutWrapper(Fake()) + assert wrapper.encoding == "utf-8" diff --git a/tests/test_testing_module.py b/tests/test_testing_module.py new file mode 100644 index 0000000..e717be6 --- /dev/null +++ b/tests/test_testing_module.py @@ -0,0 +1,69 @@ +"""Phase 6 — NitroTestingModule harness (Python-only).""" +from __future__ import annotations + +import asyncio +import os +import sys + +from pydantic import BaseModel + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from nitrostack import ( + DIContainer, + ExecutionContext, + NitroTestingModule, + injectable, + module, + prompt, + resource, + tool, +) +def setup_function() -> None: + DIContainer.reset() + + +def teardown_function() -> None: + DIContainer.reset() + + +class EchoIn(BaseModel): + value: str = "hi" + + +@injectable() +class HarnessController: + @tool(name="echo", description="echo", input_schema=EchoIn) + async def echo(self, input: EchoIn, context: ExecutionContext) -> dict: + return {"value": input.value} + + @resource(uri="note://hello", name="hello", description="json note") + async def note(self, context: ExecutionContext) -> dict: + return {"ok": True} + + @prompt(name="greet", description="greet") + async def greet(self, arguments, context: ExecutionContext) -> list: + return [{"role": "user", "content": "hello"}] + + +@module(name="harness", controllers=[HarnessController]) +class HarnessModule: + pass + + +def test_testing_module_tool_resource_prompt(): + harness = asyncio.run(NitroTestingModule.create(HarnessModule)) + assert asyncio.run(harness.call_tool("echo", {"value": "ada"})) == {"value": "ada"} + assert asyncio.run(harness.read_resource("note://hello")) == {"ok": True} + messages = asyncio.run(harness.get_prompt("greet", {})) + assert messages[0].role == "user" + + +def test_testing_module_requires_bootstrapped_server(): + harness = NitroTestingModule.__new__(NitroTestingModule) + harness.app = type("A", (), {"mcp_server": None})() + try: + asyncio.run(harness.call_tool("echo", {})) + raise AssertionError("expected RuntimeError") + except RuntimeError as exc: + assert "bootstrapped" in str(exc) diff --git a/tests/test_transport_http.py b/tests/test_transport_http.py new file mode 100644 index 0000000..9c0b8e6 --- /dev/null +++ b/tests/test_transport_http.py @@ -0,0 +1,124 @@ +"""Phase 6 — extra HTTP transport cases (Python-only).""" +from __future__ import annotations + +import asyncio +import time +import os +import sys + +from pydantic import BaseModel +from starlette.testclient import TestClient + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from nitrostack import DIContainer, ExecutionContext, injectable, module, tool +from nitrostack.core.app import McpApplicationFactory, ServerConfig, mcp_app +from nitrostack.transports.http import RequestTraceMiddleware, SessionCapMiddleware, build_http_app + +JSON_HEADERS = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"} + + +def setup_function() -> None: + DIContainer.reset() + + +def teardown_function() -> None: + DIContainer.reset() + + +class EchoIn(BaseModel): + value: str = "x" + + +@injectable() +class HttpExtraController: + @tool(name="echo", description="echo", input_schema=EchoIn) + async def echo(self, input: EchoIn, context: ExecutionContext) -> str: + return input.value + + +@module(name="http-extra", controllers=[HttpExtraController]) +class HttpExtraModule: + pass + + +def _app(name: str = "http-extra"): + @mcp_app(module=HttpExtraModule, server=ServerConfig(name=name, stateless=False)) + class App: + pass + + return asyncio.run(McpApplicationFactory.create(App)) + + +def test_landing_html_escapes_server_name(): + app = _app(name='') + http_app = build_http_app(app, enable_cors=True, stateless=True, json_response=True) + with TestClient(http_app) as client: + page = client.get("/") + assert page.status_code == 200 + assert "