diff --git a/.github/workflows/cflite_build.yml b/.github/workflows/cflite_build.yml deleted file mode 100644 index 9c49958..0000000 --- a/.github/workflows/cflite_build.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: ClusterFuzzLite continuous builds -on: - push: - branches: - - main -permissions: read-all -jobs: - Build: - runs-on: ubuntu-latest - concurrency: - group: ${{ github.workflow }}-${{ matrix.sanitizer }}-${{ github.ref }} - cancel-in-progress: true - strategy: - fail-fast: false - matrix: - sanitizer: - - address - steps: - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 - with: - fetch-depth: 0 - persist-credentials: false - - name: Build Fuzzers (${{ matrix.sanitizer }}) - id: build - uses: google/clusterfuzzlite/actions/build_fuzzers@52ecc61cb587ee99c26825a112a21abf19c7448c # v1 - with: - language: python - sanitizer: ${{ matrix.sanitizer }} - upload-build: true diff --git a/.github/workflows/cflite_cron.yml b/.github/workflows/cflite_cron.yml deleted file mode 100644 index c5e47fa..0000000 --- a/.github/workflows/cflite_cron.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: ClusterFuzzLite cron tasks - -on: - schedule: - - cron: '0 0 * * *' # Runs daily at midnight -permissions: - contents: read - -jobs: - Pruning: - runs-on: ubuntu-latest - steps: - - name: Build Fuzzers - id: build - uses: google/clusterfuzzlite/actions/build_fuzzers@52ecc61cb587ee99c26825a112a21abf19c7448c # v1 - with: - language: python - - name: Run Fuzzers - id: run - uses: google/clusterfuzzlite/actions/run_fuzzers@52ecc61cb587ee99c26825a112a21abf19c7448c # v1 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - fuzz-seconds: 600 - mode: 'prune' - output-sarif: true - - Coverage: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 - with: - fetch-depth: 0 - persist-credentials: false - - - name: Build Fuzzers - id: build - uses: google/clusterfuzzlite/actions/build_fuzzers@52ecc61cb587ee99c26825a112a21abf19c7448c # v1 - with: - language: python - sanitizer: coverage - - name: Run Fuzzers - id: run - uses: google/clusterfuzzlite/actions/run_fuzzers@52ecc61cb587ee99c26825a112a21abf19c7448c # v1 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - fuzz-seconds: 600 - mode: 'coverage' - sanitizer: 'coverage' diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8787ef2..a15ac36 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -105,7 +105,7 @@ repos: # Git commit quality - repo: https://github.com/commitizen-tools/commitizen - rev: v4.17.0 + rev: v4.18.0 hooks: - id: commitizen name: "🌳 git · Validate commit message" @@ -134,7 +134,7 @@ repos: additional_dependencies: [".[toml]"] - repo: https://github.com/semgrep/pre-commit - rev: 'v1.173.0' + rev: 'v1.177.0' hooks: - id: semgrep name: "🔒 security · Static analysis (semgrep)" @@ -145,16 +145,17 @@ repos: hooks: - id: pip-audit name: "🔒 security · Audit Python dependencies" + additional_dependencies: ["pip>=26.2"] - repo: https://github.com/woodruffw/zizmor-pre-commit - rev: v1.29.0 + rev: v1.30.1 hooks: - id: zizmor name: "🔒 security · Scan GitHub Actions (zizmor)" # Spelling and typos - repo: https://github.com/crate-ci/typos - rev: v1.49.0 + rev: v1.50.1 hooks: - id: typos name: "📝 spelling · Check typos" @@ -170,7 +171,7 @@ repos: files: ^\.github/workflows/.*\.ya?ml$ - repo: https://github.com/ariebovenberg/slotscheck - rev: v0.20.1 + rev: v0.21.0 hooks: - id: slotscheck name: "🔍 check · slotscheck" @@ -183,7 +184,7 @@ repos: - hypothesis - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.16.3 + rev: v0.16.7 hooks: - id: ruff-check name: "🐍 lint · Check with Ruff" @@ -213,14 +214,14 @@ repos: exclude: ^samples/ - repo: https://github.com/RobertCraigie/pyright-python - rev: v1.1.411 + rev: v1.1.414 hooks: - id: pyright name: "🐍 types · Check with pyright" # Python project configuration - repo: https://github.com/abravalheri/validate-pyproject - rev: v0.25 + rev: '0.26' hooks: - id: validate-pyproject name: "🐍 config · Validate pyproject.toml" diff --git a/CHANGELOG.md b/CHANGELOG.md index c289002..a3469ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,48 @@ ______________________________________________________________________ ## [Unreleased] -______________________________________________________________________ +### Added + +- **Static O(1) Route Additions:** Registered missing static route definitions in `ROUTE_MAP` for `contactslist_csvdata` and `batchjob_csverror`. +- **Sub-Action Hyphenation:** Re-implemented native CamelCase-to-kebab-case transformation for sub-actions in `Endpoint.__init__` (e.g., `statistics_linkClick` $\\rightarrow$ `statistics/link-click`). +- **Stream Query Parameter Casting:** Added `Endpoint._cast_query_param` and `_normalize_stream_filters` to automatically parse and cast multidict query filter values (such as those from `urllib.parse.parse_qs`) to `int`, `float`, `bool`, `list`, `tuple`, or `set`. +- **Custom Headers Support:** Added optional `headers` parameter to `Endpoint.__call__`, `create()`, and `update()`. +- **Fuzz Dictionary Expansion:** Added route tokens, streaming keywords, HTML/XSS triggers, IDN tags, and secret patterns to `tests/fuzz/fuzzer.dict`. + +### Changed + +- **Stream Generator Termination:** Enhanced `Endpoint.stream()` to check `Total` from response bodies, automatically halting iteration when `current_offset + len(data) >= total`. +- **Redaction of Complex Types:** Expanded `RedactingFilter` to recursively sanitize `typing.NamedTuple` instances (preserving structure), objects with `model_dump()` (e.g., Pydantic models), `__dict__`-based instances, and `set`/`frozenset` collections. +- **Path Control Character Neutralization:** `SecurityGuard.sanitize_log_trace` now cleans unprintable ASCII control characters (`[\x00-\x1f\x7f]`) before whitespace normalization. +- **Email IDN Parsing:** Switched IDN extraction in `SecurityGuard.normalize_domain` to `rpartition("@")` and scoped exception interception directly to `UnicodeError`. +- **Sample Updates:** Refactored `samples/segments_sample.py` to use `campaigndraft` and updated `samples/smoke_readme_runner.py` health checks to use canonical endpoints `eventcallbackurl` and `template_contents`. + +### Deprecated + +- **Deprecation Advisory Mapping:** Introduced `DEPRECATION_ADVISORY` in `routes.py` to emit non-breaking `DeprecationWarning` notices pointing to recommended replacements: + - `newsletter` and sub-resources (`newsletter_*`) $\\rightarrow$ `campaigndraft` / `campaigndraft_*` + - Legacy statistics (`apikeytotals`, `campaignstatistics`, `liststatistics`, `domainstatistics`, etc.) $\\rightarrow$ `statcounters` and `statistics_recipientEsp` + - Removed SDK alias `webhook` $\\rightarrow$ official REST resource `eventcallbackurl` + - Legacy template endpoints (`template_update`, `templates_contents`) $\\rightarrow$ `template.update(id=...)` or `template_detailcontent` (v3) / `template_contents` (v1) + +### Security + +- **Strict Timeout Type Guard:** Explicitly blocked boolean flags (`True`/`False`) in `SecurityGuard.validate_timeout` to prevent coercion to numeric `1.0`/`0.0` seconds. +- **Regular File Validation (CWE-400):** Added explicit `Path.is_file()` verification in `SecurityGuard.check_file_size` before evaluating file stats. +- **Header Injection Screen (CWE-113):** Broadened header sanitization in `Client.api_call` to accept `Mapping[str, str | None]` and screen non-`None` values against CRLF injection patterns. + +### Removed + +- **ClusterFuzzLite Workflows:** Removed redundant `.github/workflows/cflite_build.yml` and `.github/workflows/cflite_cron.yml` CI tasks. + +### Pull Requests Merged + +- PR #149: Deprecate endpoints. +- PR #148: build(deps): bump github/codeql-action/upload-sarif from 4.37.7 to 4.37.9. +- PR #147: build(deps): bump github/codeql-action/analyze from 4.37.7 to 4.37.9. +- PR #146: build(deps): bump github/codeql-action/analyze from 4.37.6 to 4.37.7. +- PR #145: build(deps): bump github/codeql-action/upload-sarif from 4.37.6 to 4.37.7. +- PR #144: build(deps): bump google/osv-scanner-action/osv-scanner-action from 2.5.0 to 2.5.1. ## [1.8.0] - 2026-08-17 diff --git a/PERFORMANCE.md b/PERFORMANCE.md index a27f385..f6eca29 100644 --- a/PERFORMANCE.md +++ b/PERFORMANCE.md @@ -68,6 +68,23 @@ The modern v1.8.0 release incorporates security hardening, input sanitization, a *Note: Benchmarks measure network-isolated internal overhead using mocked `responses`. Testing hardware: Darwin-CPython-3.12-64bit.* +### v1.8.0 vs. Current Refined (v1.9.0) + +Version 1.9.0 introduces route deprecation advisories, dynamic stream parameter casting, and custom header forwarding without adding measurable latency to the request execution hot path. + +| Metric | Baseline (v1.8.0) | Current Refined (v1.9.0) | Delta / Notes | +| :---------------------------------- | :---------------- | :----------------------- | :------------------------------------ | +| **Routing Speed (Mean)** | ~87.15 ns | **~87.25 ns** | **Identical (>11.4 Million Ops/sec)** | +| **Routing Speed (Min)** | ~76.25 ns | **~76.25 ns** | **Zero Latency Regression** | +| **Routing Ops/Sec** | ~11,475 Kops/s | **~11,461 Kops/s** | **High Throughput Maintained** | +| **Request Cycle (Mean)** | ~177.46 µs | **~180.98 µs** | **Stable (\<2% Mock Delta)** | +| **Cold-Boot Init Time** | ~0.142 s | **~0.102 s** | **~28.2% Profiler Speedup** | +| **Wall-Clock Startup Time** | ~0.194 s | **~0.152 s** | **~21.3% Faster Startup** | +| **Message Builder Validation** | ~1.17 µs | **~1.17 µs** | **Stable** | +| **Idempotency Fingerprint Hashing** | ~2.98 µs | **~2.99 µs** | **Stable** | + +*Note: Benchmarks measure network-isolated internal overhead using mocked `responses`. Testing hardware: Apple M4 Pro, Darwin-CPython-3.12-64bit.* + ______________________________________________________________________ ## Profiling the Codebase diff --git a/README.md b/README.md index ecb7a90..0cfa649 100644 --- a/README.md +++ b/README.md @@ -518,7 +518,8 @@ message = ( .add_cc("copilot@mailjet.com") .set_subject("Your Boarding Pass") .set_content(html="

Welcome aboard!

") - .attach_file("tickets/pass.pdf") # Safely encodes using memory-efficient ChunkedStreamer + # Safely encodes using memory-efficient ChunkedStreamer + .attach_file("tickets/pass.pdf") .attach_inline("assets/logo.png") # Adds inline attachments seamlessly .build() ) @@ -551,6 +552,16 @@ result = mailjet.contact.create(data=data) print(result.json()) ``` +The support for explicit per-request custom HTTP headers (e.g. tracking or custom metadata) across `create()`, `update()`, and direct endpoint calls. + +```python +# Pass custom per-request headers (screened for CRLF safety) +result = mailjet.contact.create( + data={"Email": "pilot@mailjet.com"}, + headers={"X-Custom-Source": "Onboarding-Service"}, +) +``` + ##### Using actions ```python @@ -639,6 +650,17 @@ for contact in mailjet.contact.stream(chunk_size=500): print(contact["Email"]) ``` +Resuming pagination from an existing offset or raw query parameters + +```python +from urllib.parse import parse_qs + +# Seamlessly handles multidicts from parse_qs (offset, limit) +query = parse_qs("offset=500&limit=100") +for contact in mailjet.contact.stream(filters=query, chunk_size=100): + print(contact["Email"]) +``` + #### PUT (Update / Patch specific fields) A `PUT` request in the Mailjet API will work as a `PATCH` request - the update will affect only the specified properties. The other properties of an existing resource will neither be modified, nor deleted. It also means that all non-mandatory properties can be omitted from your payload. @@ -786,6 +808,15 @@ The SDK includes an active native Python deprecation system to protect your appl If you attempt to use legacy arguments (like `ensure_ascii` or `data_encoding`), obsolete utility functions (`parse_response`), or ambiguous routing (`v1` with `/template`), the SDK will **not** break your code. It will successfully execute the request but will emit a non-breaking `DeprecationWarning` to help you gracefully migrate to modern standards. +### Deprecated API Endpoints & Routes + +When calling retired Mailjet endpoints, the SDK executes the request but emits an actionable `DeprecationWarning` directing you to canonical replacements: + +- **Newsletters (`newsletter*`):** Migrate to `campaigndraft` and `campaigndraft_*`. +- **Legacy Statistics (`campaignstatistics`, `liststatistics`, `domainstatistics`, `apikeytotals`):** Migrate to `statcounters` or `statistics_recipientEsp`. +- **Webhook Alias (`client.webhook`):** Use the official REST resource `client.eventcallbackurl`. +- **Ambiguous Templates (`templates_contents`):** Use `template_detailcontent` (v3) or `template_contents` (v1). + ## Type Hinting This SDK is fully type-hinted and compatible with static type checkers like `mypy` and `pyright`. diff --git a/SECURITY.md b/SECURITY.md index ec5d56a..2ed1121 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,8 +6,8 @@ We currently provide security updates for the active major version of the Mailje | Version | Supported | | ------- | ------------------ | -| 1.8.x | :white_check_mark: | -| < 1.8.0 | :x: | +| 1.9.x | :white_check_mark: | +| < 1.9.0 | :x: | # Vulnerability Disclosure diff --git a/mailjet_rest/client.py b/mailjet_rest/client.py index 4dd92ba..04dfa55 100644 --- a/mailjet_rest/client.py +++ b/mailjet_rest/client.py @@ -43,6 +43,7 @@ if TYPE_CHECKING: + from collections.abc import Mapping from types import TracebackType from mailjet_rest.types import HttpMethod, PayloadType, TimeoutType @@ -242,7 +243,7 @@ def _execute_request( url: str, headers: dict[str, Any], data: Any, - params: dict[str, Any] | None, + params: Mapping[str, str | None] | None, timeout: Any, **kwargs: Any, ) -> requests.Response: @@ -314,7 +315,7 @@ def api_call( # ruff: ignore[complex-structure] url: str, filters: dict[str, Any] | None = None, data: PayloadType = None, - headers: dict[str, str] | None = None, + headers: Mapping[str, str | None] | None = None, timeout: TimeoutType = None, **kwargs: Any, ) -> requests.Response: @@ -325,20 +326,15 @@ def api_call( # ruff: ignore[complex-structure] url (str): The fully constructed API URL. filters (dict[str, Any] | None, optional): Query parameters. data (PayloadType, optional): Request payload. - headers (dict[str, str] | None, optional): Custom HTTP headers. + headers (Mapping[str, str | None] | None, optional): Custom HTTP headers. timeout (TimeoutType, optional): Request timeout. **kwargs (Any): Additional arguments passed to 'requests.Session.request'. Returns: requests.Response: The authenticated HTTP response from Mailjet. """ - # Ensure headers is a dictionary to prevent crashes if a legacy call explicitly passes None, - # or relies on the default fallback, before we attempt to mutate it for Idempotency keys. - if headers is None: - headers = {} - - # CWE-113: Prevent Request Smuggling / CRLF Injection in headers - headers = SecurityGuard.sanitize_headers(headers) + # Ensure headers is a dictionary and screened for CRLF injections (CWE-113) + req_headers: dict[str, str | None] = {} if headers is None else SecurityGuard.sanitize_headers(headers) if not kwargs.get("verify", True): sys.audit("mailjet.security.tls_disabled", url) @@ -364,19 +360,19 @@ def api_call( # ruff: ignore[complex-structure] return mock # Allow idempotency hashing for valid batch lists - if isinstance(data, (dict, list)) and "Idempotency-Key" not in headers: - headers["Idempotency-Key"] = SecurityGuard.generate_payload_fingerprint(data) + if isinstance(data, (dict, list)) and "Idempotency-Key" not in req_headers: + req_headers["Idempotency-Key"] = SecurityGuard.generate_payload_fingerprint(data) # Strip None filters clean_filters = {k: v for k, v in filters.items() if v is not None} if filters else None - trace_suffix, _ = self._extract_telemetry(data, headers) + trace_suffix, _ = self._extract_telemetry(data, req_headers) try: response = self._execute_request( method=method, url=url, - headers=headers, + headers=req_headers, data=data, params=clean_filters, timeout=req_timeout, @@ -417,11 +413,12 @@ def _log_request(method: str, url: str, response: requests.Response, trace_str: logger.debug("API Success %s | %s %s%s", getattr(response, "status_code", 200), method, url, trace_str) @staticmethod - def _extract_telemetry(data: Any, _headers: dict[str, str] | None) -> tuple[str, dict[str, str]]: + def _extract_telemetry(data: Any, _headers: Mapping[str, str | None] | None) -> tuple[str, dict[str, str]]: """Extract tracing identifiers for safe logging and structured telemetry. Args: data (Any): The request payload. + _headers (Mapping[str, str | None] | None): Request headers. Returns: tuple[str, dict[str, str]]: A tuple containing the formatted telemetry trace suffix diff --git a/mailjet_rest/endpoint.py b/mailjet_rest/endpoint.py index 64ee69b..a35f517 100644 --- a/mailjet_rest/endpoint.py +++ b/mailjet_rest/endpoint.py @@ -6,13 +6,13 @@ import warnings from typing import TYPE_CHECKING, Any -from mailjet_rest.routes import ROUTE_MAP +from mailjet_rest.routes import DEPRECATION_ADVISORY, ROUTE_MAP from mailjet_rest.types import _JSON_HEADERS, _TEXT_HEADERS, HttpMethod, PayloadType, TimeoutType from mailjet_rest.utils.guardrails import SecurityGuard if TYPE_CHECKING: - from collections.abc import Generator + from collections.abc import Generator, Mapping import requests @@ -33,8 +33,28 @@ def __init__(self, client: Client, name: str) -> None: self.client = client self.name = name self._name_lower = name.lower() - self._action_parts = self._name_lower.split("_") - self._resource_lower = self._action_parts[0] + parts = name.split("_") + + # Base resource ignores CamelCase-to-dash conversion + self._resource_lower = parts[0].lower() + self._action_parts = [self._resource_lower] + + # Re-implement camelCase-to-dash conversion natively for sub-actions + if len(parts) > 1: + for part in parts[1:]: + dashed = "".join("-" + c.lower() if c.isupper() else c for c in part) + self._action_parts.append(dashed.lstrip("-")) + + def _check_deprecation(self) -> None: + """Emit a non-breaking warning when a deprecated route is invoked.""" + target = self._name_lower if self._name_lower in DEPRECATION_ADVISORY else self._resource_lower + if target in DEPRECATION_ADVISORY: + replacement = DEPRECATION_ADVISORY[target] + warnings.warn( + f"Endpoint '{target}' is deprecated in the Mailjet API. Migrate to '{replacement}'.", + DeprecationWarning, + stacklevel=3, + ) def _resolve_registry_route( self, base_url: str, version: str, id_val: int | str | None, action_id: int | str | None @@ -146,6 +166,7 @@ def _build_url(self, id_val: int | str | None = None, action_id: int | str | Non Returns: str: The fully qualified, sanitized secure URL. """ + self._check_deprecation() version = self.client.config.version # Test Parity DX warning: @@ -176,24 +197,93 @@ def _build_url(self, id_val: int | str | None = None, action_id: int | str | Non return url - def _build_headers(self, custom_headers: dict[str, str] | None = None) -> dict[str, str]: + def _build_headers(self, custom_headers: Mapping[str, str | None] | None = None) -> dict[str, str | None]: """Build headers based on the endpoint requirements. Args: - custom_headers (dict[str, str] | None): Custom headers to merge. + custom_headers: Custom headers to merge. Returns: - dict[str, str]: The composed dictionary of HTTP headers. + dict[str, str | None]: The composed dictionary of HTTP headers. """ base_headers = _TEXT_HEADERS if self._name_lower.endswith("_csvdata") else _JSON_HEADERS if custom_headers: - clean_custom = SecurityGuard.sanitize_headers(custom_headers) - merged = dict(base_headers) + clean_custom = SecurityGuard.sanitize_headers(dict(custom_headers)) + merged: dict[str, str | None] = dict(base_headers) merged.update(clean_custom) return merged return dict(base_headers) + @staticmethod + def _cast_query_param(orig_ref: Any, raw_values: Any) -> Any: + """Cast query string values to match the target filter parameter type. + + Handles single values or lists from urllib.parse.parse_qs and casts them + to bool, int, float, list, tuple, set, or str based on orig_ref. + + Args: + orig_ref: Reference value or type indicating the intended target type. + raw_values: List of string values or a single scalar value. + + Returns: + The parsed value cast to the target type. + """ + vals = list(raw_values) if isinstance(raw_values, (list, tuple, set)) else [raw_values] + + if not vals: + empty_defaults: dict[type, Any] = { + bool: False, + int: 0, + float: 0.0, + list: [], + tuple: (), + set: set(), + } + return empty_defaults.get(type(orig_ref), "") + + first_val = vals[0] + if isinstance(orig_ref, bool): + return str(first_val).strip().lower() in {"true", "1", "yes", "t"} + + converters: dict[type, Any] = { + int: lambda: int(first_val), + float: lambda: float(first_val), + list: lambda: list(vals), + tuple: lambda: tuple(vals), + set: lambda: set(vals), + } + converter = converters.get(type(orig_ref)) + if converter: + return converter() + + return first_val if len(vals) == 1 else vals + + @classmethod + def _normalize_stream_filters( + cls, filters: Mapping[str, Any] | None, chunk_size: int + ) -> tuple[dict[str, Any], int]: + """Normalize filter keys and extract initial offset for pagination. + + Returns: + tuple[dict[str, Any], int]: A tuple containing the sanitized filters + dictionary (with Limit set) and the validated starting offset. + """ + current_filters = dict(filters) if filters else {} + raw_offset = current_filters.pop("offset", None) + if raw_offset is None: + raw_offset = current_filters.get("Offset") + + try: + current_offset: int = cls._cast_query_param(0, raw_offset) if raw_offset is not None else 0 + except (ValueError, TypeError) as e: + msg = f"stream() Offset filter must be an integer, got: {raw_offset!r}" + raise ValueError(msg) from e + + current_filters.pop("limit", None) + current_filters["Limit"] = chunk_size + return current_filters, current_offset + def __call__( self, method: HttpMethod = "GET", @@ -202,6 +292,7 @@ def __call__( filters: dict[str, Any] | None = None, action_id: int | str | None = None, timeout: TimeoutType = None, + headers: Mapping[str, str | None] | None = None, **kwargs: Any, ) -> requests.Response: """Execute the specific HTTP method on the constructed endpoint. @@ -213,13 +304,18 @@ def __call__( filters (dict[str, Any] | None): Query string URL parameters. action_id (int | str | None): Sub-action ID. timeout (TimeoutType): Request timeout. + headers (Mapping[str, str | None] | None): Custom HTTP request headers. **kwargs (Any): Additional arguments. Returns: requests.Response: The resulting HTTP response from the request execution. """ - # Pop deprecated/HTTP kwargs safely - headers = kwargs.pop("headers", None) + # Pop deprecated/HTTP kwargs safely without overwriting the explicit 'headers' argument + if headers is None: + headers = kwargs.pop("headers", None) + else: + kwargs.pop("headers", None) + ensure_ascii = kwargs.pop("ensure_ascii", None) data_encoding = kwargs.pop("data_encoding", None) @@ -233,10 +329,13 @@ def __call__( data_str = json.dumps(data, ensure_ascii=ensure_ascii if ensure_ascii is not None else True) data = data_str.encode(data_encoding) if data_encoding else data_str + # Screen and merge headers through _build_headers + merged_headers = self._build_headers(headers) + return self.client.api_call( method=method, url=self._build_url(id_val=id, action_id=action_id), - headers=self._build_headers(headers), + headers=merged_headers, data=data, filters=filters, timeout=timeout, @@ -248,73 +347,92 @@ def get( id: int | str | None = None, filters: dict[str, Any] | None = None, action_id: int | str | None = None, + headers: Mapping[str, str | None] | None = None, **kwargs: Any, ) -> requests.Response: - """Perform a GET request. + """Perform a GET request on the constructed endpoint. Args: - id (int | str | None): The primary resource ID. - filters (dict[str, Any] | None): Query string URL parameters. - action_id (int | str | None): Sub-action ID. - **kwargs (Any): Additional args passed to requests. + id: The primary resource ID. + filters: Query string URL parameters. + action_id: Sub-action ID. + headers: Custom HTTP request headers. + **kwargs: Additional arguments passed to the request layer. Returns: - requests.Response: The resulting HTTP response for the GET request. + requests.Response: The resulting HTTP response from the GET request. """ - return self(method="GET", id=id, filters=filters, action_id=action_id, **kwargs) + return self(method="GET", id=id, filters=filters, action_id=action_id, headers=headers, **kwargs) def stream( self, id: int | str | None = None, - filters: dict[str, Any] | None = None, + filters: Mapping[str, Any] | None = None, action_id: int | str | None = None, chunk_size: int = 1000, + method: HttpMethod = "GET", **kwargs: Any, ) -> Generator[dict[str, Any], None, None]: """Automatically paginates over GET requests yielding resource dictionaries. Args: - id (int | str | None): The primary resource ID. - filters (dict[str, Any] | None): Query string URL parameters. - action_id (int | str | None): Sub-action ID. - chunk_size (int): Objects returned per loop (Limit). Defaults to 1000. - **kwargs (Any): Additional args passed to requests. + id: The primary resource ID. + filters: Query string URL parameters. Accepts dicts, MappingProxy, or parse_qs multi-dicts. + action_id: Sub-action ID. + chunk_size: Objects returned per loop (Limit). Defaults to 1000. + method: The HTTP method to use (must be GET). + **kwargs: Additional arguments passed to requests. Yields: dict[str, Any]: Individual resource objects from the paginated API response. + + Raises: + ValueError: If method is not GET, chunk_size <= 0, or Offset is invalid. """ - # Prevent infinite CPU/Network loops if 0 or negative numbers are passed + if method.upper() != "GET": + msg = f"stream() is designed for GET requests only, got {method}" + raise ValueError(msg) + if chunk_size <= 0: msg = "stream() chunk_size must be a strictly positive integer." raise ValueError(msg) - current_filters = dict(filters) if filters else {} - current_filters["Limit"] = chunk_size - - # Respect user-provided offsets to allow stream resumption. - # Cast to int to prevent TypeError when adding chunk_size later. - # Protect against 'None' values throwing a TypeError when cast to int - offset_val = current_filters.get("Offset") - current_filters["Offset"] = int(offset_val) if offset_val is not None else 0 + current_filters, current_offset = self._normalize_stream_filters(filters, chunk_size) while True: - response = self.get(id=id, filters=current_filters, action_id=action_id, **kwargs) - body = response.json() - data = body.get("Data", []) + current_filters["Offset"] = current_offset + response = self.get(id=id, filters=current_filters.copy(), action_id=action_id, **kwargs) + + if hasattr(response, "raise_for_status"): + response.raise_for_status() + + try: + body = response.json() + except (ValueError, AttributeError): + break + + if not isinstance(body, dict): + break + + data = body.get("Data") or [] + if not isinstance(data, list): + break yield from data - # Break early if we've reached the absolute end - if not data or len(data) < chunk_size: + total = body.get("Total") + reached_total = isinstance(total, int) and (current_offset + len(data) >= total) + if not data or len(data) < chunk_size or reached_total: break - current_filters["Offset"] += chunk_size + current_offset += chunk_size def create( self, data: PayloadType = None, id: int | str | None = None, action_id: int | str | None = None, + headers: Mapping[str, str | None] | None = None, ensure_ascii: bool | None = None, data_encoding: str | None = None, **kwargs: Any, @@ -325,8 +443,9 @@ def create( data (PayloadType): Request payload. id (int | str | None): The primary resource ID. action_id (int | str | None): The sub-action ID. - ensure_ascii (bool | None): Ensure ASCII serialization (Deprecated). - data_encoding (str | None): Data encoding string (Deprecated). + headers (dict[str, str] | None): Custom headers. + ensure_ascii (bool | None): Ensure ASCII encoding (deprecated). + data_encoding (str | None): Data encoding (deprecated). **kwargs (Any): Additional arguments. Returns: @@ -337,6 +456,7 @@ def create( id=id, data=data, action_id=action_id, + headers=headers, ensure_ascii=ensure_ascii, data_encoding=data_encoding, **kwargs, @@ -347,6 +467,7 @@ def update( id: int | str, data: PayloadType = None, action_id: int | str | None = None, + headers: Mapping[str, str | None] | None = None, ensure_ascii: bool | None = None, data_encoding: str | None = None, **kwargs: Any, @@ -355,10 +476,11 @@ def update( Args: id (int | str): The primary resource ID. - data (PayloadType): Updated payload. + data (PayloadType): Request payload. action_id (int | str | None): The sub-action ID. - ensure_ascii (bool | None): Ensure ASCII serialization (Deprecated). - data_encoding (str | None): Data encoding string (Deprecated). + headers (dict[str, str] | None): Custom headers. + ensure_ascii (bool | None): Ensure ASCII encoding (deprecated). + data_encoding (str | None): Data encoding (deprecated). **kwargs (Any): Additional arguments. Returns: @@ -369,6 +491,7 @@ def update( id=id, data=data, action_id=action_id, + headers=headers, ensure_ascii=ensure_ascii, data_encoding=data_encoding, **kwargs, diff --git a/mailjet_rest/routes.py b/mailjet_rest/routes.py index 53a82e3..851dc4d 100644 --- a/mailjet_rest/routes.py +++ b/mailjet_rest/routes.py @@ -20,6 +20,33 @@ class Route(NamedTuple): RouteMapType = dict[str, Route] +# Advisory mapping for legacy and deprecated endpoints +DEPRECATION_ADVISORY: Final[MappingProxyType[str, str]] = MappingProxyType( + { + # Newsletters -> Campaign Drafts (Official Mailjet Deprecation) + "newsletter": "campaigndraft", + "newsletter_detailcontent": "campaigndraft_detailcontent", + "newsletter_schedule": "campaigndraft_schedule", + "newsletter_send": "campaigndraft_send", + "newsletter_status": "campaigndraft_status", + "newsletter_test": "campaigndraft_test", + # Legacy Statistics -> Statcounters & Recipient ESP (Official Mailjet Deprecation) + "apikeytotals": "statcounters", # pragma: allowlist secret + "campaigngraphstatistics": "statcounters", + "campaignstatistics": "statcounters", + "domainstatistics": "statistics_recipientEsp", + "graphstatistics": "statcounters", + "liststatistics": "statcounters", + "messagestatistics": "statcounters", + "openstatistics": "statcounters", + "senderstatistics": "statcounters", + # Removed SDK route alias -> Official REST resource + "webhook": "eventcallbackurl", + # Redundant / Ambiguous Template routes + "template_update": "template.update(id=...)", + "templates_contents": "template_detailcontent (v3) or template_contents (v1)", + } +) _ROUTE_MAP: RouteMapType = { # ========================================== @@ -28,6 +55,7 @@ class Route(NamedTuple): "send": Route(None, "send"), "batch": Route(None, "batch"), "batchjob": Route(None, "REST/batchjob"), + "batchjob_csverror": Route(None, "DATA/batchjob/{id}/CSVError/text:csv"), # ========================================== # Messages # ========================================== @@ -37,49 +65,57 @@ class Route(NamedTuple): "messagesentstatistics": Route(None, "REST/messagesentstatistics"), "messagestate": Route(None, "REST/messagestate"), # ========================================== + # Message Events + # ========================================== + "bouncestatistics": Route(None, "REST/bouncestatistics"), + "clickstatistics": Route(None, "REST/clickstatistics"), + "openinformation": Route(None, "REST/openinformation"), + # ========================================== + # Webhooks & Parse API + # ========================================== + "eventcallbackurl": Route(None, "REST/eventcallbackurl"), + "webhook": Route(None, "REST/webhook"), + "parseroute": Route(None, "REST/parseroute"), + # ========================================== # Contacts # ========================================== "contact": Route(None, "REST/contact"), - "contactslist": Route(None, "REST/contactslist"), - # Bulk Contact Management - "contact_managemanycontacts": Route(None, "REST/contact/managemanycontacts"), - "contactslist_importlist": Route(None, "REST/contactslist/{id}/importlist"), - "contactslist_managemanycontacts": Route(None, "REST/contactslist/{id}/managemanycontacts"), - "csvimport": Route(None, "REST/csvimport"), - # Contact Properties "contactdata": Route(None, "REST/contactdata"), "contactmetadata": Route(None, "REST/contactmetadata"), - # Subscriptions - "contact_getcontactslists": Route(None, "REST/contact/{id}/getcontactslists"), - "contact_managecontactslists": Route(None, "REST/contact/{id}/managecontactslists"), - "contactslist_managecontact": Route(None, "REST/contactslist/{id}/managecontact"), + "contactslist": Route(None, "REST/contactslist"), + "contactslist_csvdata": Route(None, "DATA/contactslist/{id}/CSVData/text:plain"), "contactslistsignup": Route(None, "REST/contactslistsignup"), "listrecipient": Route(None, "REST/listrecipient"), - # Verifications + "csvimport": Route(None, "REST/csvimport"), + # Contact Sub-resources & Actions + "contact_managemanycontacts": Route(None, "REST/contact/managemanycontacts"), + "contact_managecontactslists": Route(None, "REST/contact/{id}/managecontactslists"), + "contact_getcontactslists": Route(None, "REST/contact/{id}/getcontactslists"), + "contactslist_managemanycontacts": Route(None, "REST/contactslist/{id}/managemanycontacts"), + "contactslist_importlist": Route(None, "REST/contactslist/{id}/importlist"), + "contactslist_managecontact": Route(None, "REST/contactslist/{id}/managecontact"), "contactslist_verify": Route(None, "REST/contactslist/{id}/verify"), # ========================================== - # Campaigns + # Segmentation # ========================================== - # Drafts + "contactfilter": Route(None, "REST/contactfilter"), + # ========================================== + # Campaigns & Newsletters + # ========================================== + "campaign": Route(None, "REST/campaign"), "campaigndraft": Route(None, "REST/campaigndraft"), "campaigndraft_detailcontent": Route(None, "REST/campaigndraft/{id}/detailcontent"), "campaigndraft_schedule": Route(None, "REST/campaigndraft/{id}/schedule"), "campaigndraft_send": Route(None, "REST/campaigndraft/{id}/send"), "campaigndraft_status": Route(None, "REST/campaigndraft/{id}/status"), "campaigndraft_test": Route(None, "REST/campaigndraft/{id}/test"), - # Newsletters + # Deprecated Newsletters (Maintained for Backward Compatibility) "newsletter": Route(None, "REST/newsletter"), "newsletter_detailcontent": Route(None, "REST/newsletter/{id}/detailcontent"), "newsletter_schedule": Route(None, "REST/newsletter/{id}/schedule"), "newsletter_send": Route(None, "REST/newsletter/{id}/send"), "newsletter_status": Route(None, "REST/newsletter/{id}/status"), "newsletter_test": Route(None, "REST/newsletter/{id}/test"), - # Sent Campaigns - "campaign": Route(None, "REST/campaign"), - # ========================================== - # Segmentation - # ========================================== - "contactfilter": Route(None, "REST/contactfilter"), # ========================================== # Templates # ========================================== @@ -91,17 +127,18 @@ class Route(NamedTuple): "template_contents": Route("v1", "REST/templates/{id}/contents"), "template_content_by_type": Route("v1", "REST/templates/{id}/contents/types/{action_id}"), # ========================================== - # Statistics + # Active Statistics # ========================================== + "statcounters": Route(None, "REST/statcounters"), "campaignoverview": Route(None, "REST/campaignoverview"), "contactstatistics": Route(None, "REST/contactstatistics"), "geostatistics": Route(None, "REST/geostatistics"), "listrecipientstatistics": Route(None, "REST/listrecipientstatistics"), - "statcounters": Route(None, "REST/statcounters"), "statistics_linkClick": Route(None, "REST/statistics/link-click"), "statistics_recipientEsp": Route(None, "REST/statistics/recipient-esp"), "toplinkclicked": Route(None, "REST/toplinkclicked"), "useragentstatistics": Route(None, "REST/useragentstatistics"), + # Deprecated Statistics (Maintained for Backward Compatibility) "apikeytotals": Route(None, "REST/apikeytotals"), "campaigngraphstatistics": Route(None, "REST/campaigngraphstatistics"), "campaignstatistics": Route(None, "REST/campaignstatistics"), @@ -112,18 +149,6 @@ class Route(NamedTuple): "openstatistics": Route(None, "REST/openstatistics"), "senderstatistics": Route(None, "REST/senderstatistics"), # ========================================== - # Message Events - # ========================================== - "bouncestatistics": Route(None, "REST/bouncestatistics"), - "clickstatistics": Route(None, "REST/clickstatistics"), - "openinformation": Route(None, "REST/openinformation"), - # ========================================== - # Webhook & Parse - # ========================================== - "eventcallbackurl": Route(None, "REST/eventcallbackurl"), - "webhook": Route(None, "REST/webhook"), - "parseroute": Route(None, "REST/parseroute"), - # ========================================== # Sender Addresses and Domains # ========================================== "sender": Route(None, "REST/sender"), @@ -132,7 +157,7 @@ class Route(NamedTuple): "dns": Route(None, "REST/dns"), "dns_check": Route(None, "REST/dns/{id}/check"), # ========================================== - # Settings (API Key Configuration & Account) + # Settings (API Keys & Account) # ========================================== "apikey": Route(None, "REST/apikey"), "apikeyaccess": Route(None, "REST/apikeyaccess"), diff --git a/mailjet_rest/utils/guardrails.py b/mailjet_rest/utils/guardrails.py index 5d796eb..cb4e246 100644 --- a/mailjet_rest/utils/guardrails.py +++ b/mailjet_rest/utils/guardrails.py @@ -27,6 +27,8 @@ if TYPE_CHECKING: + from collections.abc import Mapping + import requests from mailjet_rest.types import TimeoutType @@ -198,15 +200,49 @@ def _redact_str(data: str) -> str: except Exception: # ruff: ignore[blind-except] return "[REDACTION_FAILED_UNSAFE_STRING]" + def _redact_tuple(self, data: tuple[Any, ...], depth: int) -> tuple[Any, ...]: + """Recursively sanitize tuple items preserving namedtuple structure. + + Returns: + The sanitized tuple or NamedTuple instance. + """ + if hasattr(data, "_fields"): # Preserves typing.NamedTuple + with contextlib.suppress(Exception): + return type(data)(*(self._deep_redact(item, depth + 1) for item in data)) + return tuple(self._deep_redact(item, depth + 1) for item in data) + + def _redact_object(self, data: Any, depth: int) -> Any: + """Recursively sanitize custom objects, dataclasses, and Pydantic models. + + Returns: + The sanitized dictionary, string, or primitive representation. + """ + if hasattr(data, "model_dump") and callable(data.model_dump): + with contextlib.suppress(Exception): + return self._deep_redact(data.model_dump(), depth + 1) + + if hasattr(data, "__dict__"): + with contextlib.suppress(Exception): + return self._deep_redact(vars(data), depth + 1) + + try: + str_val = str(data) + except Exception: # ruff: ignore[blind-except] + return "" + + return self._redact_str(str_val) + def _deep_redact(self, data: Any, depth: int = 0) -> Any: """Recursively search and scrub secrets from complex nested data structures. Returns: - Any: The fully scrubbed and redacted data structure representation. + The fully sanitized data structure representation. """ if depth > self.MAX_REDACTION_DEPTH: return "[MAX_DEPTH_REACHED]" + if isinstance(data, (int, float, bool, type(None))): + return data if isinstance(data, str): return self._redact_str(data) if isinstance(data, dict): @@ -214,11 +250,11 @@ def _deep_redact(self, data: Any, depth: int = 0) -> Any: if isinstance(data, list): return [self._deep_redact(item, depth + 1) for item in data] if isinstance(data, tuple): - return tuple(self._deep_redact(item, depth + 1) for item in data) + return self._redact_tuple(data, depth) if isinstance(data, set): return {self._deep_redact(item, depth + 1) for item in data} - return data + return self._redact_object(data, depth) @override def filter(self, record: logging.LogRecord) -> bool: @@ -341,15 +377,15 @@ def check_request_security(kwargs: dict[str, Any]) -> None: warnings.warn("Security Warning: Unencrypted HTTP proxy detected.", UserWarning, stacklevel=3) @staticmethod - def sanitize_headers(headers: dict[str, str]) -> dict[str, str]: + def sanitize_headers(headers: Mapping[str, str | None]) -> dict[str, str | None]: """Prevent HTTP Header Injection (CWE-113). Returns: dict[str, str]: The sanitized headers safely screened for CRLF injections. """ - clean_headers = {} + clean_headers: dict[str, str | None] = {} for k, v in headers.items(): - if _CRLF_RE.search(k) or _CRLF_RE.search(str(v)): + if _CRLF_RE.search(k) or (v is not None and _CRLF_RE.search(str(v))): sys.audit("mailjet.security.header_injection", k) msg = f"Security Violation: CRLF injection detected in header '{k}'" raise ValueError(msg) @@ -492,9 +528,14 @@ def validate_attachment_path(file_path: Path | str, safe_base_dir: Path | str | @staticmethod def check_file_size(path: Path, max_size_bytes: int = 15 * 1024 * 1024) -> None: """Prevent Resource Exhaustion (CWE-400). Limit defaults to 15MB.""" - size = path.stat().st_size + target = Path(path) + if not target.is_file(): + msg = f"Security Alert (CWE-400): Path is not a regular file: {target}" + raise ValueError(msg) + + size = target.stat().st_size if size > max_size_bytes: - msg = f"Security Violation: File '{path.name}' exceeds safe threshold." + msg = f"Security Violation: File '{target.name}' exceeds safe threshold." raise ValueError(msg) @staticmethod @@ -507,7 +548,8 @@ def _validate_scalar_timeout(timeout: Any) -> float: Returns: float: The validated scalar timeout in seconds. """ - if not isinstance(timeout, (int, float)): + # Explicitly check for bool to prevent True/False coercing to 1.0/0.0 + if isinstance(timeout, bool) or not isinstance(timeout, (int, float)): msg = f"Timeout must be a numeric float or int, got {type(timeout).__name__}." raise TypeError(msg) @@ -580,23 +622,21 @@ def normalize_domain(email_or_domain: str) -> str: """ if not email_or_domain: return email_or_domain - parts = email_or_domain.rsplit("@", 1) - if len(parts) == 2: - local, domain = parts + local_part, sep, domain_part = email_or_domain.rpartition("@") + if sep: try: - puny_domain = domain.encode("idna").decode("ascii") - except Exception as e: + puny_domain = domain_part.encode("idna").decode("ascii") + except UnicodeError as e: msg = f"Invalid IDN in email: {email_or_domain}" raise ValueError(msg) from e - else: - return f"{local}@{puny_domain}" - else: - try: - return email_or_domain.encode("idna").decode("ascii") - except Exception as e: - msg = f"Invalid IDN: {email_or_domain}" - raise ValueError(msg) from e + return f"{local_part}@{puny_domain}" + + try: + return email_or_domain.encode("idna").decode("ascii") + except UnicodeError as e: + msg = f"Invalid IDN: {email_or_domain}" + raise ValueError(msg) from e @staticmethod def sanitize_segment(segment: Any) -> str: @@ -669,7 +709,10 @@ def sanitize_log_trace(trace_val: Any) -> str: """ if not trace_val: return "" - return re.sub(r"\s+", "_", str(trace_val)) + + # Neutralize control characters [\x00-\x1f\x7f] and normalize whitespace + clean_str = _PATH_CONTROL_CHAR_RE.sub("_", str(trace_val)) + return re.sub(r"\s+", "_", clean_str) @staticmethod def _validate_token(auth: str) -> str: diff --git a/samples/segments_sample.py b/samples/segments_sample.py index a8a39d1..7570008 100644 --- a/samples/segments_sample.py +++ b/samples/segments_sample.py @@ -27,7 +27,7 @@ def create_a_campaign_with_a_segmentation_filter(segmentation_id=0): "ContactsListID": 0, "SegmentationID": segmentation_id, } - return mailjet30.newsletter.create(data=data) + return mailjet30.campaigndraft.create(data=data) if __name__ == "__main__": diff --git a/samples/smoke_readme_runner.py b/samples/smoke_readme_runner.py index daf9a2e..12d67b3 100644 --- a/samples/smoke_readme_runner.py +++ b/samples/smoke_readme_runner.py @@ -27,7 +27,6 @@ def section(title: str) -> None: def safe_cleanup(action, name, **kwargs): """Executes a cleanup action without failing on permission (401) or consistency (404) errors.""" try: - # Temporarily silence SDK error logs for cleanup to keep output clean client_logger = logging.getLogger("mailjet_rest.client") old_level = client_logger.level client_logger.setLevel(logging.CRITICAL) @@ -42,7 +41,6 @@ def safe_cleanup(action, name, **kwargs): except MailjetAuthError: print(f"⚠️ CLEANUP: {name} skipped (Permission denied: Operation not allowed).") except DoesNotExistError: - # The SDK now correctly raises DoesNotExistError for 404 responses print(f"⚠️ CLEANUP: {name} skipped (Not found: likely eventual consistency delay).") except Exception as e: print(f"❌ CLEANUP: {name} raised unexpected exception: {e}") @@ -57,17 +55,13 @@ def run_readme_tests(): print("⚠️ Missing Mailjet API credentials in environment variables.") return - # Using the Context Manager (Best Practice for resource management) with ( Client(auth=(api_key, api_secret), version="v3.1") as mailjet_v31, Client(auth=(api_key, api_secret), version="v3") as mailjet_v3, Client(auth=content_token or (api_key, api_secret), version="v1") as mailjet_v1, ): - # --------------------------------------------------------------------- - # 1. SEND API (v3.1) - Sanitized Telemetry - # --------------------------------------------------------------------- + # 1. SEND API (v3.1) section("Send API (v3.1) - Basic Email & Telemetry") - message = ( MessageBuilder() .set_sender("pilot@mailjet.com", "Mailjet Pilot") @@ -75,58 +69,42 @@ def run_readme_tests(): .set_subject("README Test: Your email flight plan!") .set_content(text="Welcome to Mailjet!") ).build() - - # Verification: Check logs to see this sanitized to '_' (CWE-117) message["CustomID"] = "Readme_Test\n[CRITICAL]_INJECTION_ATTEMPT" - payload = SendPayloadBuilder().add_message(message).set_sandbox_mode(True).build() - res = mailjet_v31.send.create(data=payload) assert res.status_code == 200, f"Failed Send API: {res.text}" print("✅ Send API passed (Check logs for sanitized CustomID).") - # --------------------------------------------------------------------- - # 2. SECURITY GUARDRAILS (Poka-Yoke Verification) - # --------------------------------------------------------------------- + # 2. SECURITY GUARDRAILS section("Security Guardrails (Active Protection)") - - # 1. Test CRLF Injection try: mailjet_v3.contact.get(headers={"X-Injected": "value\r\nBadHeader: true"}) assert False, "SDK failed to block CRLF injection." except ValueError as e: print(f"✅ Guardrail Success: Blocked Header Injection - '{e}'") - # 2. Test TLS Bypass (MITM Prevention) try: - # We explicitly test that the SDK refuses insecure connections mailjet_v3.contact.get(verify=False) assert False, "SDK allowed insecure TLS connection." except ValueError as e: print(f"✅ Guardrail Success: Blocked Insecure TLS - '{e}'") - # --------------------------------------------------------------------- - # 3. STANDARD REST ACTIONS (Contact Lifecycle) - # --------------------------------------------------------------------- + # 3. STANDARD REST ACTIONS section("Standard REST Actions (Contact Lifecycle)") - test_email = f"readme_test_{uuid.uuid4().hex[:8]}@mailjet.com" res = mailjet_v3.contact.create(data={"Email": test_email}) assert res.status_code == 201 contact_id = res.json()["Data"][0]["ID"] print(f"✅ POST (Create Contact) passed. Created ID: {contact_id}") - # GET (Read all & Filtering & Pagination) res = mailjet_v3.contact.get(filters={"limit": 2, "sort": "Email desc"}) assert res.status_code == 200 print("✅ GET (Read all/Pagination) passed.") - # GET (Read one) res = mailjet_v3.contact.get(id=contact_id) assert res.status_code == 200 print("✅ GET (Read one) passed.") - # PUT (Update Contact Metadata) prop_name = f"test_prop_{uuid.uuid4().hex[:6]}" res_meta = mailjet_v3.contactmetadata.create(data={"Datatype": "str", "Name": prop_name, "NameSpace": "static"}) if res_meta.status_code == 201: @@ -135,21 +113,13 @@ def run_readme_tests(): res = mailjet_v3.contactdata.update(id=contact_id, data=update_data) assert res.status_code == 200 print("✅ PUT (Update Contact Data) passed.") - # Resilient Teardown: Metadata safe_cleanup(mailjet_v3.contactmetadata.delete, f"Metadata {prop_id}", id=prop_id) - # Resilient Teardown: Contact safe_cleanup(mailjet_v3.contact.delete, f"Contact {contact_id}", id=contact_id) - # --------------------------------------------------------------------- - # 4. EMAIL API ECOSYSTEM (Webhooks, Parse, Segmentation, Stats) - # --------------------------------------------------------------------- + # 4. EMAIL API ECOSYSTEM section("Email API Ecosystem") - - # Webhooks webhook_url = f"https://www.example.com/webhook_{uuid.uuid4().hex[:6]}" - - # Prevent MJ18 Conflict by checking for an existing webhook first get_webhook = mailjet_v3.eventcallbackurl.get() if get_webhook.status_code == 200 and get_webhook.json().get("Data"): w_id = get_webhook.json()["Data"][0]["ID"] @@ -164,10 +134,7 @@ def run_readme_tests(): print("✅ Webhooks (eventcallbackurl) created/updated.") safe_cleanup(mailjet_v3.eventcallbackurl.delete, f"Webhook {w_id}", id=w_id) - # Parse API parse_url = f"https://www.example.com/parse_{uuid.uuid4().hex[:6]}" - - # Prevent MJ18 Conflict by checking for an existing route first get_parse = mailjet_v3.parseroute.get() if get_parse.status_code == 200 and get_parse.json().get("Data"): p_id = get_parse.json()["Data"][0]["ID"] @@ -180,7 +147,6 @@ def run_readme_tests(): print("✅ Parse API (parseroute) created/updated.") safe_cleanup(mailjet_v3.parseroute.delete, f"ParseRoute {p_id}", id=p_id) - # Segmentation res = mailjet_v3.contactfilter.create( data={ "Description": "README Test Filter", @@ -193,19 +159,14 @@ def run_readme_tests(): print("✅ Segmentation (contactfilter) created.") safe_cleanup(mailjet_v3.contactfilter.delete, f"ContactFilter {f_id}", id=f_id) - # Statcounters res = mailjet_v3.statcounters.get( filters={"CounterSource": "APIKey", "CounterTiming": "Message", "CounterResolution": "Lifetime"} ) assert res.status_code == 200 print("✅ Statcounters passed.") - # --------------------------------------------------------------------- - # 5. CONTENT API (v1) - Full Image Lifecycle - # --------------------------------------------------------------------- + # 5. CONTENT API (v1) section("Content API (v1)") - - # Negative Upload (Verifying error handling) client_logger = logging.getLogger("mailjet_rest.client") prev_level = client_logger.level client_logger.setLevel(logging.CRITICAL) @@ -219,7 +180,6 @@ def run_readme_tests(): finally: client_logger.setLevel(prev_level) - # Real Multipart Upload & Resilient Cleanup b64_string = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=" files_payload = { "metadata": (None, '{"name": "readme_logo.png", "Status": "open"}', "application/json"), @@ -230,10 +190,7 @@ def run_readme_tests(): if res.status_code == 201: image_id = res.json()["Data"][0]["ID"] print(f"✅ Content API Upload passed. Image ID: {image_id}") - - # CRITICAL: Wait 1 second for the server to process the upload before trying to delete it. time.sleep(1) - try: safe_cleanup(mailjet_v1.data_images.delete, f"Image {image_id}", id=image_id) except Exception: @@ -241,20 +198,16 @@ def run_readme_tests(): else: print(f"⚠️ Content API Upload skipped/failed: {res.status_code}") - # --------------------------------------------------------------------- - # 6. ADDITIONAL HEALTH CHECKS (Read-Only & RPC-Actions) - # --------------------------------------------------------------------- + # 6. ADDITIONAL HEALTH CHECKS section("Additional Health Checks (Read-Only & RPC-Actions)") - - # Strategy: 'stream' for list/GET-collections, 'ping' for POST/RPC-actions health_checks = [ ("Send", mailjet_v3.send, "ping", None), ("Contacts", mailjet_v3.contact, "stream", None), - ("Webhooks", mailjet_v3.webhook, "ping", None), + ("Webhooks", mailjet_v3.eventcallbackurl, "ping", None), ("Sender Validate", mailjet_v3.sender_validate, "ping", 999999), ("Tokens (v1)", mailjet_v1.tokens, "stream", None), ("Labels (v1)", mailjet_v1.labels, "stream", None), - ("Template Contents", mailjet_v1.templates_contents, "stream", 999999), + ("Template Contents (v1)", mailjet_v1.template_contents, "stream", 999999), ("Senders", mailjet_v3.sender, "stream", None), ("Campaigns", mailjet_v3.campaign, "stream", None), ("Messages", mailjet_v3.message, "stream", None), @@ -266,7 +219,6 @@ def run_readme_tests(): if strategy == "stream": iterator = endpoint.stream(id=resource_id, chunk_size=1) item = next(iterator, None) - if item is not None: print(f"✅ {name} (stream) passed (Found items).") else: @@ -279,7 +231,6 @@ def run_readme_tests(): assert False, f"Unexpected status {res.status_code}" except Exception as e: - # Check if the status code is on the domain exception, or nested inside the HTTP error cause status = getattr(e, "status_code", None) if status is None and getattr(e, "__cause__", None) is not None: response = getattr(e.__cause__, "response", None) diff --git a/tests/fuzz/fuzz_core.py b/tests/fuzz/fuzz_core.py index 77ebe14..1dc44b8 100644 --- a/tests/fuzz/fuzz_core.py +++ b/tests/fuzz/fuzz_core.py @@ -1,9 +1,12 @@ import sys +import warnings import atheris import gc from mailjet_rest.errors import MailjetAuthError, ValidationError, MailjetApiError +# Silence deprecation warnings during fuzzing to reduce overhead and log noise +warnings.simplefilter("ignore") # Instrument all internal modules with atheris.instrument_imports(enable_loader_override=False): @@ -25,7 +28,6 @@ def raise_for_status(self): pass _mock_client.session.request = lambda *args, **kwargs: DummyResponse() # type: ignore[method-assign, assignment] - def fuzz_config(fdp: atheris.FuzzedDataProvider) -> int: """Target 1: Config Validation.""" try: @@ -37,6 +39,7 @@ def fuzz_config(fdp: atheris.FuzzedDataProvider) -> int: ) return 0 except (ValueError, TypeError): + # We expect ValueError, TypeError return -1 @@ -83,7 +86,8 @@ def fuzz_telemetry_and_difflib(fdp: atheris.FuzzedDataProvider) -> int: try: getattr(_mock_client, fdp.ConsumeUnicodeNoSurrogates(150)) except AttributeError: - pass # We expect typos to raise AttributeError + # We expect typos to raise AttributeError + pass # Fuzz telemetry extractor with lists and dicts num_keys = fdp.ConsumeIntInRange(1, 10) @@ -114,8 +118,9 @@ def TestOneInput(data: bytes) -> int: global execution_counter execution_counter += 1 - # Force garbage collection more frequently to prevent 2GB OOM peaks - if execution_counter % 2500 == 0: + # Periodically flush the client endpoint cache and run garbage collection + if execution_counter % 1000 == 0: + _mock_client._endpoint_cache.clear() gc.collect() if len(data) < 5: diff --git a/tests/fuzz/fuzz_endpoint.py b/tests/fuzz/fuzz_endpoint.py index 7fc8a79..4f4a172 100644 --- a/tests/fuzz/fuzz_endpoint.py +++ b/tests/fuzz/fuzz_endpoint.py @@ -67,6 +67,22 @@ def TestOneInput(data: bytes) -> None: for _ in range(fdp.ConsumeIntInRange(1, 5)): dynamic_headers[fdp.ConsumeUnicodeNoSurrogates(15)] = fdp.ConsumeUnicodeNoSurrogates(30) + # Target pagination and generator parsing + if fdp.ConsumeBool(): + chunk_size = fdp.ConsumeIntInRange(-10, 2000) + try: + for idx, _ in enumerate(endpoint.stream( + id=id_val, + action_id=action_id, + filters=filters, + chunk_size=chunk_size, + )): + if idx > 5: + break + except (ValueError, TypeError, ValidationError): + # Expected for malformed fuzzed inputs traversing validation logic + pass + payload = {fdp.ConsumeUnicodeNoSurrogates(5): fdp.ConsumeUnicodeNoSurrogates(10)} if method_idx == 0: diff --git a/tests/fuzz/fuzz_guardrails.py b/tests/fuzz/fuzz_guardrails.py index 1bf2b2b..270447e 100644 --- a/tests/fuzz/fuzz_guardrails.py +++ b/tests/fuzz/fuzz_guardrails.py @@ -49,6 +49,25 @@ def TestOneInput(data: bytes) -> None: payload = fdp.ConsumeUnicodeNoSurrogates(256) SecurityGuard.check_control_characters("fuzzed_field", payload) + elif target == 5: + # Target 6: Auth coercion (Tuple vs String Token vs Malformed) + auth_val = fdp.ConsumeUnicodeNoSurrogates(128) if fdp.ConsumeBool() else ( + fdp.ConsumeUnicodeNoSurrogates(32), + fdp.ConsumeUnicodeNoSurrogates(32), + ) + SecurityGuard.validate_and_coerce_auth(auth_val) + + elif target == 6: + # Target 7: HTML SpamGuard static parser + html = fdp.ConsumeUnicodeNoSurrogates(512) + SecurityGuard.analyze_html_safety(html) + + elif target == 7: + # Target 8: Safe Kwargs Filtering (Mass Assignment / CWE-915) + k = fdp.ConsumeUnicodeNoSurrogates(16) + v = fdp.ConsumeUnicodeNoSurrogates(16) + SecurityGuard.filter_safe_kwargs({k: v}) + except (ValueError, TypeError, FileNotFoundError, AttributeError): # SECURITY SUCCESS: The fail-closed architecture intercepted the malformed data. pass diff --git a/tests/fuzz/fuzz_redacting_filter.py b/tests/fuzz/fuzz_redacting_filter.py index 3cab537..8b56c8f 100644 --- a/tests/fuzz/fuzz_redacting_filter.py +++ b/tests/fuzz/fuzz_redacting_filter.py @@ -34,6 +34,10 @@ def generate_deep_dict(fdp: atheris.FuzzedDataProvider, depth: int = 0) -> Any: fdp.ConsumeUnicodeNoSurrogates(10): generate_deep_dict(fdp, depth + 1) for _ in range(fdp.ConsumeIntInRange(1, 3)) } + choice = fdp.ConsumeIntInRange(0, 5) + if choice == 4: + # Fuzz Set and Object redaction pathways + return {fdp.ConsumeUnicodeNoSurrogates(16) for _ in range(fdp.ConsumeIntInRange(1, 3))} return None diff --git a/tests/fuzz/fuzzer.dict b/tests/fuzz/fuzzer.dict index 6f2f314..a02f4c4 100644 --- a/tests/fuzz/fuzzer.dict +++ b/tests/fuzz/fuzzer.dict @@ -58,6 +58,10 @@ "APIKey" "SecretKey" "Token" +"MAX_DEPTH_REACHED" +"REDACTION_FAILED_UNSAFE_STRING" +"Authorization: Basic " +"Authorization: Bearer " # Common state flags & analytics @@ -107,6 +111,12 @@ "messagehistory" "messageinformation" "messagestate" +"contactslist_csvdata" +"batchjob_csverror" +"contact_data" +"widget" +"widgetcustomdesign" +"template_content_by_type" # Expected Exception & State Tracking Overrides @@ -1820,3 +1830,196 @@ "\x00Q" "\xe9\x93\xa6\xee\x8a\xaf\xeb\xa2\xbd\xe8\x93\xa2\xef\xbe\x80\xe3\x81\xa5%8e" "\xcc\x04\x00\x00\x00\x00\x00\x00" +"\xef\xbc\x80\xe0\xb8\x80\x01\x00\xe2\x94\x80\xe3\x81\xa5%8e\xef\xbf\xbf" +"\x00\xe8\x80\x80\x00\xe3\xbc\x80\xe3\x81\xa5\xef\xbf\xbd\xe5\xbc\x80\xe6\x94\xa5" +"]]]]]\x00%e0" +"\xe3\xac\xae\xe2\x84\xbc[\xe2\xb8\xaf\xe3\xac\xae\xe3\x94\xaf\xef\xbf\xbd\xe5\xb4\x80" +"(\x00\x00\x00\x00\x00\x00\x00" +"\xff\xff\xff\xff\xff\xff\xff\x1c" +"!!!" +"\xe1\x80\x80\xc4\x80\xe2\x94\x80\xe3\x81\xa5%8e\xc2\x80" +"\x00\xdb\x00" +"5\x00" +"%Pu%\xef\xbf\xbdE22" +"\x00\x01\x00" +"\xe7\xb1\xa1\xe4\x8d\xbc\xe7\x91\xa5\xe6\x96\xb3 \xcc\x85" +"\xe3\x81\xa1\xe7\x91\xaf\xe6\x96\xb3\xca\x81" +"\x00\x00\x00\x00\x00\x0e\x00\x06" +"contactslist_csvdata" +"batchjob_csverror" +"contact_data" +"widget" +"widgetcustomdesign" +"template_content_by_type" +"template_detailcontent" +"template_contents" +"templates_contents" +"DATA/contactslist" +"DATA/batchjob" +"CSVData/text:plain" +"CSVError/text:csv" +"contents/lock" +"contents/unlock" +"contents/publish" +"data_images" +"tokens" +"labels" +"images" +"Offset" +"offset" +"Limit" +"limit" +"chunk_size" +"raise_for_status" +"Total" +"Count" +"Data" +"sort" +"" +"\x00\x00\x00\x00\x00\x00\x00\xfc" +"\xe6\xa5\xb4\xe7\x95\xae\xe3\x81\xa5%8e" +"[D%[\xef\xbf\xbds\x7f" +"\x01\x00\x00\x00\x00\x00\x00\xfd" +"0\"\"%e0%\x008\x00" +"\x01\x00\x00\x00\x00\x00\x04\x02" +"d\x1e?\x00c\x00\x00%8e" +"\xef\xbf\xbdK" +"%\xef\xbf\xbd%" +"%%%%D2" +"%%\xef\xbf\xbdK" +"\x00w" +"%%%%D2%" +"\x00\xdb" +"64" +"\x00\x00\x00\x00\x00\x00\x00\x89" +"\xcf\x06\x00\x00\x00\x00\x00\x00" +"\"_%3%3%33" +"/\"" +"~\x00\x00" +"\xe3\xac\xae\xe4\xa4\xaf\xe2\xb8\xa1.\xe3\x94\xaf\xef\xbf\xbd\xe5\xb4\x80" +"%\xef\xbf\xbd%8" +"%e7%e7%a\x0e\x00" +"\xffI" +"]\x00]]]]%e0&" +"\x804" +"\x10\x00\x00" +"\xef\x88\x81\xe4\x90\x90\xc9\x87\xe8\x93\x90\xe3\x82\x9a\xef\xbf\xbd" +"=\xfb=" +"%\xef\xbf\xbd2/\x00" +"&\x02\x00\x00\x00\x00\x00\x00" +"\x03\x03\x00\x00\x00\x00\x00\x00" diff --git a/tests/integration/test_client.py b/tests/integration/test_client.py index 3e5a73a..898f89d 100644 --- a/tests/integration/test_client.py +++ b/tests/integration/test_client.py @@ -203,10 +203,10 @@ def test_registry_parity_and_integrity(client_live: Client, route_key: str) -> N endpoint = getattr(client_live, route_key) kwargs = {} - if "{" in ROUTE_MAP[route_key].path: + if "{id}" in ROUTE_MAP[route_key].path: kwargs["id_val"] = "123" - if "{action_id}" in ROUTE_MAP[route_key].path: - kwargs["action_id"] = "test" + if "{action_id}" in ROUTE_MAP[route_key].path: + kwargs["action_id"] = "test" url = endpoint._build_url(**kwargs) parsed = urlparse(url) @@ -721,3 +721,200 @@ def test_live_client_edge_cases_and_utilities(client_live: Client) -> None: mock_res = requests.Response() mock_res._content = b'{"Count": 1}' assert parse_response(mock_res) == {"Count": 1} + + +def test_live_custom_headers_propagation(client_live: Client) -> None: + """Verify that custom headers pass cleanly through endpoint operations.""" + custom_headers = {"X-Request-Source": "CI-Runner", "X-Integration-Test": "True"} + # Standard read request with custom headers + resp = client_live.contact.get(filters={"limit": 1}, headers=custom_headers) + assert resp.status_code == 200 + + +def test_live_stream_with_parse_qs_filters(client_live: Client) -> None: + """Verify endpoint.stream correctly casts multidicts from parse_qs during live pagination.""" + from urllib.parse import parse_qs + + # parse_qs produces list values: {'offset': ['0'], 'limit': ['2']} + query_filters = parse_qs("offset=0&limit=2") + streamer = client_live.contact.stream(filters=query_filters, chunk_size=2) + + items = [] + for item in streamer: + items.append(item) + if len(items) >= 2: + break + + assert isinstance(items, list) + if items: + assert "ID" in items[0] + + +def test_live_new_static_routes_url_resolution(client_live: Client) -> None: + """Verify newly registered static routes resolve with correct prefixes and suffixes.""" + csv_data_url = client_live.contactslist_csvdata._build_url(id_val=123) + assert csv_data_url == "https://api.mailjet.com/v3/DATA/contactslist/123/CSVData/text:plain" + + csv_err_url = client_live.batchjob_csverror._build_url(id_val=456) + assert csv_err_url == "https://api.mailjet.com/v3/DATA/batchjob/456/CSVError/text:csv" + + +def test_live_account_metadata_endpoints(client_live: Client) -> None: + """Verify live read-only access to account and configuration endpoints.""" + # Contact metadata schema + resp_meta = client_live.contactmetadata.get(filters={"limit": 1}) + assert resp_meta.status_code == 200 + assert "Data" in resp_meta.json() + + # Domain DNS configuration + resp_dns = client_live.dns.get(filters={"limit": 1}) + assert resp_dns.status_code == 200 + assert "Data" in resp_dns.json() + + # User / API key details + resp_key = client_live.apikey.get(filters={"limit": 1}) + assert resp_key.status_code == 200 + + +@pytest.mark.parametrize("stats_endpoint", [ + "bouncestatistics", + "clickstatistics", + "openinformation", + "toplinkclicked", + "useragentstatistics", +]) +def test_live_event_statistics_endpoints(client_live: Client, stats_endpoint: str) -> None: + """Verify real-time event tracking and statistics log retrieval.""" + endpoint = getattr(client_live, stats_endpoint) + resp = endpoint.get(filters={"limit": 1}) + assert resp.status_code == 200 + assert "Data" in resp.json() + + +def test_live_eventcallbackurl_crud_lifecycle(client_live: Client) -> None: + """End-to-End test for webhook (eventcallbackurl) management.""" + webhook_url = f"https://example.com/webhook_{uuid.uuid4().hex[:8]}" + + # 1. Create Webhook + create_resp = client_live.eventcallbackurl.create( + data={ + "EventType": "open", + "Url": webhook_url, + "Status": "alive", + "IsBackup": False, + } + ) + if create_resp.status_code != 201: + pytest.skip(f"Webhook setup not permitted: {create_resp.text}") + + webhook_id = create_resp.json()["Data"][0]["ID"] + + try: + # 2. Read + get_resp = client_live.eventcallbackurl.get(id=webhook_id) + assert get_resp.status_code == 200 + assert get_resp.json()["Data"][0]["Url"] == webhook_url + + # 3. Update (pause webhook) + update_resp = client_live.eventcallbackurl.update( + id=webhook_id, + data={"Status": "dead"}, + ) + assert update_resp.status_code == 200 + finally: + # 4. Clean up + client_live.eventcallbackurl.delete(id=webhook_id) + + +from mailjet_rest.errors import DoesNotExistError, ValidationError + + +def test_live_statistics_link_click_and_recipient_esp(client_live: Client) -> None: + """Verify live read queries on camelCase-hyphenated sub-actions: + statistics/link-click and statistics/recipient-esp. + """ + # 1. Test statistics/link-click (requires CampaignID parameter) + try: + resp_click = client_live.statistics_linkClick.get(filters={"CampaignID": 1}) + assert resp_click.status_code in (200, 404) + if resp_click.status_code == 200: + assert "Data" in resp_click.json() + except (DoesNotExistError, ValidationError) as e: + # Non-existent CampaignID returns 404/400 from live API + assert getattr(e, "status_code", 404) in (400, 404) + + # 2. Test statistics/recipient-esp (requires CampaignID parameter) + try: + resp_esp = client_live.statistics_recipientEsp.get(filters={"CampaignID": 1}) + assert resp_esp.status_code in (200, 404) + if resp_esp.status_code == 200: + assert "Data" in resp_esp.json() + except (DoesNotExistError, ValidationError) as e: + assert getattr(e, "status_code", 404) in (400, 404) + + +def test_live_contact_subresources_and_actions(client_live: Client) -> None: + """Verify live URI interpolation on multi-segment contact sub-resources: + - REST/contact/{id}/managecontactslists + - REST/contact/{id}/getcontactslists + - REST/contactslist/{id}/managemanycontacts + """ + test_email = f"ci-subresource-{uuid.uuid4().hex[:8]}@example.com" + create_contact = client_live.contact.create(data={"Email": test_email}) + if create_contact.status_code != 201: + pytest.skip(f"Contact creation failed: {create_contact.text}") + + contact_id = create_contact.json()["Data"][0]["ID"] + + list_name = f"CI_List_{uuid.uuid4().hex[:8]}" + create_list = client_live.contactslist.create(data={"Name": list_name}) + if create_list.status_code != 201: + try: + client_live.contact.delete(id=contact_id) + except MailjetAuthError: + pass + pytest.skip(f"ContactsList creation failed: {create_list.text}") + + list_id = create_list.json()["Data"][0]["ID"] + + try: + # 1. Test REST/contact/{id}/managecontactslists (POST action on specific contact) + sub_payload = { + "ContactsLists": [ + {"ListID": list_id, "Action": "addnoforce"} + ] + } + resp_manage = client_live.contact_managecontactslists.create( + id=contact_id, + data=sub_payload, + ) + assert resp_manage.status_code in (200, 201) + + # 2. Test REST/contact/{id}/getcontactslists (GET sub-resource on specific contact) + resp_getlists = client_live.contact_getcontactslists.get(id=contact_id) + assert resp_getlists.status_code == 200 + assert "Data" in resp_getlists.json() + + # 3. Test REST/contactslist/{id}/managemanycontacts (POST action on specific list) + many_payload = { + "Action": "addnoforce", + "Contacts": [{"Email": test_email}], + } + resp_many = client_live.contactslist_managemanycontacts.create( + id=list_id, + data=many_payload, + ) + assert resp_many.status_code in (200, 201) + + finally: + # Resilient Teardown + try: + client_live.contact.delete(id=contact_id) + except MailjetAuthError: + # Contact deletion is restricted without GDPR delete privileges + pass + + try: + client_live.contactslist.delete(id=list_id) + except (MailjetAuthError, DoesNotExistError): + pass diff --git a/tests/unit/test_endpoint.py b/tests/unit/test_endpoint.py index fff955a..266ac11 100644 --- a/tests/unit/test_endpoint.py +++ b/tests/unit/test_endpoint.py @@ -1,7 +1,10 @@ +from urllib.parse import parse_qs import pytest +import requests import responses from mailjet_rest.client import Client +from mailjet_rest.endpoint import Endpoint @pytest.fixture @@ -89,3 +92,178 @@ def test_endpoint_dynamic_data_routing(client_offline: Client) -> None: """Coverage: Test dynamic data_ route building.""" url = client_offline.data_testroute._build_url() assert "v3/data/testroute" in url + + +def test_endpoint_init_action_parts_casing_and_hyphenation() -> None: + """Verify camelCase-to-kebab conversion and case-preservation in __init__.""" + client = Client(auth=("key", "secret")) + + # 1. Single camelCase sub-action + ep1 = Endpoint(client, "customResource_linkClick") + assert ep1._resource_lower == "customresource" + assert ep1._action_parts == ["customresource", "link-click"] + + # 2. Leading uppercase on sub-action (lstrip("-") branch) + ep2 = Endpoint(client, "customResource_LinkClick") + assert ep2._action_parts == ["customresource", "link-click"] + + # 3. Multiple sub-action parts with mixed cases + ep3 = Endpoint(client, "myResource_firstAction_secondActionName") + assert ep3._resource_lower == "myresource" + assert ep3._action_parts == ["myresource", "first-action", "second-action-name"] + + # 4. Purely lowercased resource without sub-actions + ep4 = Endpoint(client, "simpleresource") + assert ep4._resource_lower == "simpleresource" + assert ep4._action_parts == ["simpleresource"] + + +def test_endpoint_dynamic_subaction_routing_branches() -> None: + """Cover lines 123-132: dynamic fallback with id_val and action_id permutations.""" + client = Client(auth=("key", "secret"), version="v3") + + # Branch A: id_val is None -> action_id shifts to primary ID position + url_shifted = client.customResource_someAction._build_url(action_id=456) + assert url_shifted == "https://api.mailjet.com/v3/REST/customresource/456/some-action" + + # Branch B: id_val provided and action_id is None + url_id_only = client.customResource_someAction._build_url(id_val=123) + assert url_id_only == "https://api.mailjet.com/v3/REST/customresource/123/some-action" + + # Branch C: both id_val and action_id provided -> composite action path + url_both = client.customResource_someAction._build_url(id_val=123, action_id=456) + assert url_both == "https://api.mailjet.com/v3/REST/customresource/123/some-action/456" + + # Branch D: neither provided + url_none = client.customResource_someAction._build_url() + assert url_none == "https://api.mailjet.com/v3/REST/customresource/some-action" + + +def test_endpoint_dynamic_data_with_action_id() -> None: + """Cover line 121: unmapped data_ prefix with an action_id passed.""" + client = Client(auth=("key", "secret"), version="v1") + url = client.data_custom_path._build_url(action_id="sub_token") + assert url == "https://api.mailjet.com/v1/data/custom/path/sub_token" + + +def test_endpoint_stream_input_validation() -> None: + """Cover stream method and chunk_size guards (use raw string to prevent SyntaxWarning).""" + client = Client(auth=("key", "secret")) + + # Invalid HTTP method - raw string r"..." eliminates Python 3.12 SyntaxWarning + with pytest.raises(ValueError, match=r"stream\(\) is designed for GET requests only"): + next(client.contact.stream(method="POST")) + + # Invalid chunk_size <= 0 + with pytest.raises(ValueError, match=r"chunk_size must be a strictly positive integer"): + next(client.contact.stream(chunk_size=0)) + + # Invalid Offset type + with pytest.raises(ValueError, match=r"stream\(\) Offset filter must be an integer"): + next(client.contact.stream(filters={"offset": "invalid_offset"})) + + +def test_endpoint_call_deprecated_encoding_scalar_payload(monkeypatch: pytest.MonkeyPatch) -> None: + """Cover branch 252->257: ensure_ascii/data_encoding with non-dict/list payload.""" + import requests + + client = Client(auth=("key", "secret")) + endpoint = client.contact + + def mock_api_call(*args: object, **kwargs: object) -> requests.Response: + resp = requests.Response() + resp.status_code = 200 + return resp + + monkeypatch.setattr(client, "api_call", mock_api_call) + + with pytest.warns(DeprecationWarning, match="'ensure_ascii' and 'data_encoding' are deprecated"): + # Passing string data instead of dict/list tests the branch fallback + endpoint(method="POST", data="plain text data", ensure_ascii=True) + + +def test_stream_filter_normalization_and_snapshot_isolation(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify stream handles lowercase offset/limit and preserves filter immutability.""" + client = Client(auth=("key", "secret")) + captured_filters: list[dict[str, object]] = [] + + def mock_get(self: Endpoint, *args: object, **kwargs: object) -> requests.Response: + filters = kwargs.get("filters", {}) + assert isinstance(filters, dict) + captured_filters.append(filters) + resp = requests.Response() + resp.status_code = 200 + resp._content = b'{"Total": 1, "Data": [{"id": 100}]}' + return resp + + # Patch Endpoint class, not the slotted client.contact instance + monkeypatch.setattr(Endpoint, "get", mock_get) + + initial_filters = {"offset": "20", "limit": "5", "Custom": "KeepMe"} + items = list(client.contact.stream(filters=initial_filters, chunk_size=50)) + + assert len(items) == 1 + assert captured_filters[0]["Offset"] == 20 + assert captured_filters[0]["Limit"] == 50 + assert "offset" not in captured_filters[0] + assert "limit" not in captured_filters[0] + assert captured_filters[0]["Custom"] == "KeepMe" + + +def test_endpoint_cast_query_param_scalar_and_collections() -> None: + """Validate _cast_query_param against all supported Python types.""" + # Boolean casting + assert Endpoint._cast_query_param(True, ["true"]) is True + assert Endpoint._cast_query_param(True, ["1"]) is True + assert Endpoint._cast_query_param(True, "yes") is True + assert Endpoint._cast_query_param(True, ["false"]) is False + assert Endpoint._cast_query_param(True, ["0"]) is False + + # Numeric casting + assert Endpoint._cast_query_param(0, ["42"]) == 42 + assert Endpoint._cast_query_param(0, "42") == 42 + assert Endpoint._cast_query_param(0.0, ["3.14"]) == 3.14 + assert Endpoint._cast_query_param(0.0, "3.14") == 3.14 + + # Collection casting + assert Endpoint._cast_query_param([], ["a", "b"]) == ["a", "b"] + assert Endpoint._cast_query_param((), ["a", "b"]) == ("a", "b") + assert Endpoint._cast_query_param(set(), ["a", "b"]) == {"a", "b"} + + # String fallback + assert Endpoint._cast_query_param("ref", ["alpha"]) == "alpha" + assert Endpoint._cast_query_param("ref", "alpha") == "alpha" + + # Empty collection fallbacks + assert Endpoint._cast_query_param(True, []) is False + assert Endpoint._cast_query_param(0, []) == 0 + assert Endpoint._cast_query_param(0.0, []) == 0.0 + assert Endpoint._cast_query_param([], []) == [] + + +def test_stream_with_parse_qs_multidict(monkeypatch: pytest.MonkeyPatch) -> None: + """Ensure stream() seamlessly consumes parse_qs output without TypeError.""" + client = Client(auth=("key", "secret")) + captured_offsets: list[int] = [] + + def mock_get(self: Endpoint, *args: object, **kwargs: object) -> requests.Response: + filters = kwargs.get("filters", {}) + assert isinstance(filters, dict) + captured_offsets.append(filters["Offset"]) + + resp = requests.Response() + resp.status_code = 200 + # For offset=10 (with 3 items across offsets 10-12), the database Total is 13 + if filters["Offset"] == 10: + resp._content = b'{"Total": 13, "Data": [{"id": 1}, {"id": 2}]}' + else: + resp._content = b'{"Total": 13, "Data": [{"id": 3}]}' + return resp + + monkeypatch.setattr(Endpoint, "get", mock_get) + + raw_query = parse_qs("offset=10&limit=2") + items = list(client.contact.stream(filters=raw_query, chunk_size=2)) + + assert len(items) == 3 + assert captured_offsets == [10, 12] diff --git a/tests/unit/test_guardrails.py b/tests/unit/test_guardrails.py index 56e5d30..21d7fe7 100644 --- a/tests/unit/test_guardrails.py +++ b/tests/unit/test_guardrails.py @@ -1,7 +1,10 @@ # pyright: reportIndexIssue=false """Unit tests for the guardrails.py security module.""" +from __future__ import annotations + import logging +import tempfile from html.parser import HTMLParser from pathlib import Path from typing import Any @@ -9,10 +12,17 @@ import pytest -from mailjet_rest.utils.guardrails import RedactingFilter, SecretAuth, SecurityGuard +from mailjet_rest.utils.guardrails import ( + RedactingFilter, + SecretAuth, + SecureHTTPAdapter, + SecurityGuard, +) class TestRedactingFilter: + """Test suite covering secret redaction across log strings, nested structures, and objects.""" + def test_redacting_filter_scrubs_secrets_from_string(self) -> None: """Coverage: Hits the string redaction branch.""" filter_ = RedactingFilter() @@ -65,85 +75,107 @@ def test_deep_redact_stops_at_max_depth(self) -> None: assert isinstance(record.args, dict) assert record.args["a"]["b"]["c"]["d"]["e"] == "[MAX_DEPTH_REACHED]" + def test_redacting_filter_exceptions(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Coverage: Trigger string parsing failures inside logging filter.""" + filter_ = RedactingFilter() -class TestSecurityGuard: - def test_validate_config_url_valid(self) -> None: - """Coverage: Valid URL passes cleanly.""" - SecurityGuard.validate_config_url("https://api.mailjet.com/v3", "mailjet.com") + # Create a mock pattern object to bypass re.Pattern immutability + class MockPattern: + def sub(self, *args: Any, **kwargs: Any) -> str: + raise ValueError("Forced error") - def test_validate_config_url_http(self) -> None: - """Coverage: Invalid scheme is blocked (CWE-918).""" - with pytest.raises(ValueError, match="Invalid scheme 'ftp'"): - SecurityGuard.validate_config_url("ftp://api.mailjet.com") + # Mock the internal function returning the pattern instead of the pattern type itself + monkeypatch.setattr("mailjet_rest.utils.guardrails._get_secret_pattern", lambda: MockPattern()) - def test_validate_config_url_malicious_domain(self) -> None: - """Coverage: Unrecognized domains blocked (CWE-918).""" - with pytest.raises(ValueError, match="not permitted"): - SecurityGuard.validate_config_url("https://attacker.com/v3", "mailjet.com") + # It should catch the error and return the original string transparently + assert RedactingFilter._redact_str("test") == "[REDACTION_FAILED_UNSAFE_STRING]" - def test_sanitize_headers_catches_crlf(self) -> None: - """Coverage: HTTP Header Injection (CWE-113).""" - with pytest.raises(ValueError, match="CRLF injection"): - SecurityGuard.sanitize_headers({"X-Custom": "val\r\ninjected"}) + # It should catch any error in the outer filter() block and return True (allow log writing) + record = logging.LogRecord("test", logging.INFO, "", 0, "msg", (), None) + assert filter_.filter(record) is True - def test_check_control_characters(self) -> None: - """Coverage: Null byte injection (CWE-20).""" - with pytest.raises(ValueError, match="Unprintable control character"): - SecurityGuard.check_control_characters("field", "bad\x00string") + def test_redacting_filter_advanced_types_and_extras(self) -> None: + """Cover NamedTuples, sets, dataclass/object models, and extra attributes in logging.""" + filter_ = RedactingFilter() - def test_analyze_html_safety_blocks_xss(self) -> None: - """Coverage: SpamGuard catches script tags.""" - with pytest.raises(ValueError, match="executable Javascript/XSS vectors"): - SecurityGuard.analyze_html_safety("") + # 1. Custom objects with __dict__ + class CustomPayload: + def __init__(self) -> None: + self.api_key = "api_key: secret_token_value" # pragma: allowlist secret - def test_analyze_html_safety_blocks_events(self) -> None: - """Coverage: SpamGuard catches inline events.""" - with pytest.raises(ValueError, match="blocked script/event execution tags"): - SecurityGuard.analyze_html_safety("") + # 2. Objects with model_dump (Pydantic style) + class PydanticDummy: + def model_dump(self) -> dict[str, str]: + return {"token": "token 12345secret"} - def test_generate_payload_fingerprint(self) -> None: - """Coverage: Idempotency hashing mechanism.""" - payload1 = {"a": 1, "b": 2, "CustomID": "ignore"} - payload2 = {"b": 2, "a": 1, "EventPayload": "ignore"} - assert SecurityGuard.generate_payload_fingerprint(payload1) == SecurityGuard.generate_payload_fingerprint( - payload2 + # 3. Set & frozenset redaction + raw_set = {"api_key: set_secret"} + raw_frozenset = frozenset(["api_key: frozenset_secret"]) + + record = logging.LogRecord( + name="test_logger", + level=logging.INFO, + pathname="", + lineno=0, + msg="Logging structured records", + args=(CustomPayload(), PydanticDummy(), raw_set, raw_frozenset), + exc_info=None, ) + # Inject an 'extra' attribute containing key context + record.custom_audit_field = {"header": "Authorization: Bearer secret_extra_token"} # type: ignore[attr-defined] - def test_validate_attachment_path_traversal(self, tmp_path: Path) -> None: - """Coverage: Path traversal enforcement (CWE-22).""" - # The check ordering now evaluates bounds before touching the filesystem, - # throwing the correct security error! - with pytest.raises(ValueError, match="Traversal detected"): - SecurityGuard.validate_attachment_path("../../etc/passwd", tmp_path) + filter_.filter(record) - def test_check_file_size_exceeded(self, tmp_path: Path) -> None: - """Coverage: Hits CWE-400 resource exhaustion.""" - test_file = tmp_path / "large.txt" - test_file.write_bytes(b"0" * 1025) - with pytest.raises(ValueError, match="exceeds safe threshold"): - SecurityGuard.check_file_size(test_file, max_size_bytes=1000) + # Validate redactions + assert "secret_token_value" not in str(record.args) + assert "12345secret" not in str(record.args) + assert "set_secret" not in str(record.args) + assert "frozenset_secret" not in str(record.args) + assert "secret_extra_token" not in str(getattr(record, "custom_audit_field")) - def test_validate_timeout_nan_inf(self) -> None: - """Coverage: CWE-400 Float evaluation.""" - with pytest.raises(ValueError, match="Timeout cannot be Infinity or NaN"): - SecurityGuard.validate_timeout(float("inf")) - def test_normalize_domain_punycode(self) -> None: - """Coverage: IDN homograph normalization.""" - puny = SecurityGuard.normalize_domain("info@münchen.de") - assert puny == "info@xn--mnchen-3ya.de" +class TestSecretAuth: + """Test suite covering SecretAuth encapsulation, hashing, and memory protection.""" - def test_sanitize_log_trace(self) -> None: - """Coverage: CWE-117 Log Forging.""" - clean = SecurityGuard.sanitize_log_trace("My\nTrace\rID") - assert clean == "My_Trace_ID" + def test_secretauth_repr(self) -> None: + """Coverage: Confirm string representations scrub memory securely.""" + auth = SecretAuth(("user", "pass")) + assert repr(auth) == "SecretAuth(***REDACTED***)" - @patch("sys.audit") - def test_audit_hooks_are_emitted(self, mock_audit: MagicMock) -> None: - """Coverage: PEP 578 integration.""" - with pytest.raises(ValueError, match="Path traversal attempt"): - SecurityGuard.sanitize_segment("..") - mock_audit.assert_called_with("mailjet.security.path_traversal", "..") + def test_secret_auth_security_boundaries(self) -> None: + """Cover SecretAuth __hash__, __eq__, __getitem__, and __iter__ protections.""" + auth1 = SecretAuth(("user_key", "secret_pass")) + auth2 = SecretAuth(("user_key", "secret_pass")) + auth_diff = SecretAuth(("diff_key", "diff_pass")) + + # Equality comparisons + assert auth1 == auth2 + assert auth1 != auth_diff + assert auth1 != "invalid_type" + assert auth1 == ("user_key", "secret_pass") + + # Hash support for dict lookups + lookup = {auth1: "authenticated"} + assert lookup[auth2] == "authenticated" + + # Blocked indexing (CWE-316) + with pytest.raises(TypeError, match="Credential extraction via indexing is blocked"): + _ = auth1[0] + + # Blocked iteration / tuple unpacking (CWE-316) + with pytest.raises(TypeError, match="Credential extraction via iteration is blocked"): + for _ in auth1: + pass + + +class TestSecureHTTPAdapter: + """Test suite covering custom TLS 1.2+ adapter and proxy manager security.""" + + def test_secure_http_adapter_proxy_manager(self) -> None: + """Cover SecureHTTPAdapter.proxy_manager_for TLS configuration.""" + adapter = SecureHTTPAdapter() + proxy_manager = adapter.proxy_manager_for("http://127.0.0.1:8080") + assert proxy_manager.connection_pool_kw.get("ssl_context") is not None class TestAuthCoercionAndValidation: @@ -207,129 +239,247 @@ def test_invalid_auth_type(self) -> None: SecurityGuard.validate_and_coerce_auth(12345) # type: ignore[arg-type] -def test_generate_payload_fingerprint_cyclic() -> None: - """Coverage: Prevent recursion errors on cyclic references.""" - cyclic: dict[str, Any] = {} - cyclic["a"] = cyclic - # Should gracefully return a string hash without crashing - assert SecurityGuard.generate_payload_fingerprint(cyclic) - - -def test_generate_payload_fingerprint_max_depth() -> None: - """Coverage: Enforce maximum nesting depth limits.""" - deep: Any = {"a": 1} - for _ in range(55): - deep = {"a": deep} - - with pytest.raises(ValueError, match="Payload hashing failed due to malformed"): - SecurityGuard.generate_payload_fingerprint(deep) - - -def test_validate_attachment_path_no_sandbox() -> None: - """Coverage: Fallback zero-trust checks for OS roots and path traversal.""" - with pytest.raises(ValueError, match="Path traversal tokens"): - SecurityGuard.validate_attachment_path("../etc/passwd") - - with pytest.raises(ValueError, match="explicitly forbidden"): - SecurityGuard.validate_attachment_path("/etc/passwd") - - -def test_sanitize_segment_template_injection() -> None: - """Coverage: Block Jinja/Template injection signatures.""" - with pytest.raises(ValueError, match="Template injection attempt"): - SecurityGuard.sanitize_segment("{{ config.secret }}") +class TestSecurityGuard: + """Test suite covering perimeter guardrails, URI sanitization, inputs, and payloads.""" + # ------------------------------------------------------------------------- + # Configuration & URL Validation (SSRF / CWE-918) + # ------------------------------------------------------------------------- + def test_validate_config_url_valid(self) -> None: + """Coverage: Valid URL passes cleanly.""" + SecurityGuard.validate_config_url("https://api.mailjet.com/v3", "mailjet.com") -def test_sanitize_segment_invalid_type() -> None: - """Coverage: Block dicts/lists in path segments.""" - with pytest.raises(TypeError, match="Invalid segment type"): - SecurityGuard.sanitize_segment({"dict": "not allowed"}) # type: ignore[arg-type] + def test_validate_config_url_http(self) -> None: + """Coverage: Invalid scheme is blocked (CWE-918).""" + with pytest.raises(ValueError, match="Invalid scheme 'ftp'"): + SecurityGuard.validate_config_url("ftp://api.mailjet.com") + def test_validate_config_url_malicious_domain(self) -> None: + """Coverage: Unrecognized domains blocked (CWE-918).""" + with pytest.raises(ValueError, match="not permitted"): + SecurityGuard.validate_config_url("https://attacker.com/v3", "mailjet.com") -def test_spam_guard_html_analysis_htmlparser_error() -> None: - """Coverage: Trigger Failsafe on HTMLParser crash.""" + def test_check_request_security_proxy_warning(self) -> None: + """Cover unencrypted HTTP proxy warning.""" + with pytest.warns(UserWarning, match="Unencrypted HTTP proxy detected"): + SecurityGuard.check_request_security({"proxies": {"https": "http://insecure-proxy.com:8080"}}) + + def test_filter_safe_kwargs(self) -> None: + """Cover filtering out disallowed client kwargs (CWE-915).""" + input_kwargs = {"verify": True, "timeout": 10, "disallowed_header_injection": "bad"} + filtered = SecurityGuard.filter_safe_kwargs(input_kwargs) + assert "verify" in filtered + assert "timeout" not in filtered + assert "disallowed_header_injection" not in filtered + + # ------------------------------------------------------------------------- + # Headers & Control Characters (CWE-113, CWE-20, CWE-117) + # ------------------------------------------------------------------------- + def test_sanitize_headers_catches_crlf(self) -> None: + """Coverage: HTTP Header Injection (CWE-113).""" + with pytest.raises(ValueError, match="CRLF injection"): + SecurityGuard.sanitize_headers({"X-Custom": "val\r\ninjected"}) - class CrashParser(HTMLParser): - def feed(self, data: str) -> None: - raise RecursionError("Simulated crash") + def test_check_control_characters(self) -> None: + """Coverage: Null byte injection (CWE-20).""" + with pytest.raises(ValueError, match="Unprintable control character"): + SecurityGuard.check_control_characters("field", "bad\x00string") - with patch.object(SecurityGuard, "_SpamGuardParser", CrashParser): - with pytest.raises(Exception, match="Fatal HTML parsing error"): - SecurityGuard.analyze_html_safety("
") + def test_sanitize_log_trace(self) -> None: + """Coverage: CWE-117 Log Forging.""" + clean = SecurityGuard.sanitize_log_trace("My\nTrace\rID") + assert clean == "My_Trace_ID" + # ------------------------------------------------------------------------- + # Path Segment Sanitization (CWE-22, CWE-94, CWE-79, CWE-116) + # ------------------------------------------------------------------------- + def test_sanitize_segment_template_injection(self) -> None: + """Coverage: Block Jinja/Template injection signatures.""" + with pytest.raises(ValueError, match="Template injection attempt"): + SecurityGuard.sanitize_segment("{{ config.secret }}") + + def test_sanitize_segment_invalid_type(self) -> None: + """Coverage: Block dicts/lists in path segments.""" + with pytest.raises(TypeError, match="Invalid segment type"): + SecurityGuard.sanitize_segment({"dict": "not allowed"}) # type: ignore[arg-type] + + def test_sanitize_segment_double_encoding(self) -> None: + """Coverage: Protects against double-encoded path traversal attacks (CWE-116).""" + with pytest.raises(ValueError, match="Excessive URL encoding"): + # Percent encode "%25" three times -> %252525 -> %2525 -> %25 + SecurityGuard.sanitize_segment("%2525252525") + + def test_sanitize_segment_slashes(self) -> None: + """Coverage: Verify unescaped path traversals in path generation are intercepted.""" + with pytest.raises(ValueError, match="Path traversal attempt"): + SecurityGuard.sanitize_segment("a/b") + with pytest.raises(ValueError, match="Path traversal attempt"): + SecurityGuard.sanitize_segment("a\\b") -def test_secretauth_repr() -> None: - """Coverage: Confirm string representations scrub memory securely.""" - auth = SecretAuth(("user", "pass")) - assert repr(auth) == "SecretAuth(***REDACTED***)" + def test_sanitize_segment_xss(self) -> None: + """Coverage: Prevent URL-based XSS injection via dynamically generated attributes.""" + with pytest.raises(ValueError, match="XSS attempt detected"): + SecurityGuard.sanitize_segment("") + def test_analyze_html_safety_blocks_events(self) -> None: + """Coverage: SpamGuard catches inline events.""" + with pytest.raises(ValueError, match="blocked script/event execution tags"): + SecurityGuard.analyze_html_safety("") -def test_sanitize_segment_slashes() -> None: - """Coverage: Verify unescaped path traversals in path generation are intercepted.""" - with pytest.raises(ValueError, match="Path traversal attempt"): - SecurityGuard.sanitize_segment("a/b") - with pytest.raises(ValueError, match="Path traversal attempt"): - SecurityGuard.sanitize_segment("a\\b") + def test_spam_guard_html_analysis_object_embed(self) -> None: + """Coverage: Hit the alternate and branches.""" + with pytest.raises(ValueError, match="Security Violation: HTML contains executable Javascript/XSS vectors."): + SecurityGuard.analyze_html_safety("") + with pytest.raises(ValueError, match="Security Violation: HTML contains executable Javascript/XSS vectors."): + SecurityGuard.analyze_html_safety("") -def test_sanitize_segment_xss() -> None: - """Coverage: Prevent URL-based XSS injection via dynamically generated attributes.""" - with pytest.raises(ValueError, match="XSS attempt detected"): - SecurityGuard.sanitize_segment("