diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml deleted file mode 100644 index 2f7ef59..0000000 --- a/.github/workflows/cd.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: cd -on: - push: - branches: [main] - -jobs: - release-please: - runs-on: ubuntu-latest - outputs: - release_created: ${{ steps.release.outputs.release_created }} - steps: - - uses: google-github-actions/release-please-action@v4 - id: release - with: - release-type: python - package-name: netsuite - bump-minor-pre-major: true - cd: - runs-on: ubuntu-latest - needs: [release-please] - if: needs.release-please.outputs.release_created - steps: - - uses: actions/checkout@v4 - - name: ASDF Parse - uses: kota65535/github-asdf-parse-action@v2.0.0 - id: versions - - uses: actions/setup-python@v5 - with: - python-version: "${{ steps.versions.outputs.python }}" - architecture: x64 - - uses: abatilo/actions-poetry@v4.0.0 - with: - poetry-version: "${{ steps.versions.outputs.poetry }}" - - run: poetry build - - run: poetry publish - env: - POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 13065b0..9dfcc9e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,43 +7,24 @@ on: jobs: unittests: - runs-on: ${{ matrix.os }} - strategy: - matrix: - python-version: ["3.13", "3.12", "3.11", "3.10", "3.9"] - extras: ["", all, soap_api, orjson, cli] - os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: ASDF Parse - uses: kota65535/github-asdf-parse-action@v2.0.0 - id: versions - uses: actions/setup-python@v5 with: - python-version: "${{ matrix.python-version }}" + python-version: "3.12" - uses: abatilo/actions-poetry@v4.0.0 - with: - poetry-version: "${{ steps.versions.outputs.poetry }}" - - run: poetry install --extras ${{ matrix.extras }} - if: matrix.extras != '' - - run: poetry install - if: matrix.extras == '' + - run: poetry install --extras all - run: poetry run pytest -v style: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: ASDF Parse - uses: kota65535/github-asdf-parse-action@v2.0.0 - id: versions - uses: actions/setup-python@v5 with: - python-version: "${{ steps.versions.outputs.python }}" - architecture: x64 + python-version: "3.12" - uses: abatilo/actions-poetry@v4.0.0 - with: - poetry-version: "${{ steps.versions.outputs.poetry }}" - run: poetry install --extras all - run: poetry run flake8 - run: poetry run mypy --ignore-missing-imports . diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index ac443cd..eeba6a1 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -10,18 +10,13 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: ASDF Parse - uses: kota65535/github-asdf-parse-action@v2.0.0 - id: versions - uses: actions/setup-python@v5 with: - python-version: "${{ steps.versions.outputs.python }}" - architecture: x64 + python-version: "3.12" - uses: abatilo/actions-poetry@v4.0.0 - with: - poetry-version: "${{ steps.versions.outputs.poetry }}" - run: poetry install --extras all - run: poetry run pytest --cov=netsuite --cov-report=xml --cov-report=term - uses: codecov/codecov-action@v5 with: files: ./coverage.xml + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml deleted file mode 100644 index ff4ba03..0000000 --- a/.github/workflows/docs.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: docs -on: - push: - branches: [main] - -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: ASDF Parse - uses: kota65535/github-asdf-parse-action@v2.0.0 - id: versions - - uses: actions/setup-python@v5 - with: - python-version: "${{ steps.versions.outputs.python }}" - architecture: x64 - - uses: abatilo/actions-poetry@v4.0.0 - with: - poetry-version: "${{ steps.versions.outputs.poetry }}" - - run: poetry install --extras all - - run: poetry run mkdocs build - - uses: peaceiris/actions-gh-pages@v4.0.0 - with: - github_token: "${{ secrets.GITHUB_TOKEN }}" - publish_dir: ./site diff --git a/.gitignore b/.gitignore index ab7e397..f9f8455 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ /.pytest_cache /poetry.lock .DS_Store +/specs/ diff --git a/.vscode/settings.json b/.vscode/settings.json index 83d19f1..03555a8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,5 +2,7 @@ "python.formatting.provider": "black", "files.exclude": { "poetry.lock": true - } + }, + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true } diff --git a/README.md b/README.md index 593a4fe..116ca1d 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,39 @@ # netsuite -[![Continuous Integration Status](https://github.com/jacobsvante/netsuite/actions/workflows/ci.yml/badge.svg)](https://github.com/jacobsvante/netsuite/actions/workflows/ci.yml) -[![Continuous Delivery Status](https://github.com/jacobsvante/netsuite/actions/workflows/cd.yml/badge.svg)](https://github.com/jacobsvante/netsuite/actions/workflows/cd.yml) -[![Code Coverage](https://img.shields.io/codecov/c/github/jacobsvante/netsuite?color=%2334D058)](https://codecov.io/gh/jacobsvante/netsuite) -[![PyPI version](https://img.shields.io/pypi/v/netsuite.svg)](https://pypi.python.org/pypi/netsuite/) -[![License](https://img.shields.io/pypi/l/netsuite.svg)](https://pypi.python.org/pypi/netsuite/) -[![Python Versions](https://img.shields.io/pypi/pyversions/netsuite.svg)](https://pypi.org/project/netsuite/) -[![PyPI status (alpha/beta/stable)](https://img.shields.io/pypi/status/netsuite.svg)](https://pypi.python.org/pypi/netsuite/) -[![Slack Status](https://netsuite-slackin.fly.dev/badge.svg)](https://netsuite-slackin.fly.dev) +[![Continuous Integration Status](https://github.com/vlouvet/netsuite/actions/workflows/ci.yml/badge.svg)](https://github.com/vlouvet/netsuite/actions/workflows/ci.yml) +[![Code Coverage](https://img.shields.io/codecov/c/github/vlouvet/netsuite?color=%2334D058)](https://codecov.io/gh/vlouvet/netsuite) +[![License](https://img.shields.io/github/license/vlouvet/netsuite.svg)](LICENSE) -Make async requests to NetSuite SuiteTalk SOAP, REST Web Services, and Restlets. [Detailed documentation available here.](https://jacobsvante.github.io/netsuite/) +Make async requests to NetSuite SuiteTalk SOAP, REST Web Services, and Restlets. -# Help & Support +This is an unofficial fork. It is not published to PyPI; install directly from this repository. -Join the [Slack channel](https://netsuite-slackin.fly.dev) for help with NetSuite issues. Please do not post usage questions as issues in GitHub. +## Installation -There are some additional helpful resources for NetSuite development [listed here](https://dashboard.suitesync.io/docs/resources#netsuite). +Default features (REST API + Restlet support): -## Installation + pip install git+https://github.com/vlouvet/netsuite.git -With default features (REST API + Restlet support): +Pin to a specific commit or tag: - pip install netsuite + pip install git+https://github.com/vlouvet/netsuite.git@ -With Web Services SOAP API support: +With Web Services SOAP API support (deprecated by NetSuite as of the 2027.1 release — prefer REST + OAuth 2.0 for new integrations): - pip install netsuite[soap_api] + pip install "netsuite[soap_api] @ git+https://github.com/vlouvet/netsuite.git" With CLI support: - pip install netsuite[cli] + pip install "netsuite[cli] @ git+https://github.com/vlouvet/netsuite.git" With `orjson` package (faster JSON handling): - pip install netsuite[orjson] + pip install "netsuite[orjson] @ git+https://github.com/vlouvet/netsuite.git" With all features: - pip install netsuite[all] + pip install "netsuite[all] @ git+https://github.com/vlouvet/netsuite.git" ## Documentation -Is found here: https://jacobsvante.github.io/netsuite/ +In-repo documentation lives in [`docs/index.md`](docs/index.md). diff --git a/docs/index.md b/docs/index.md index b29fb89..7bdb14f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -5,30 +5,126 @@ hide: # netsuite python library -Make async requests to NetSuite SuiteTalk SOAP/REST Web Services and Restlets +Make async requests to NetSuite SuiteTalk SOAP/REST Web Services and Restlets. + +This is an unofficial fork. It is not published to PyPI; install directly from this repository. ## Installation With default features (REST API + Restlet support): - pip install netsuite + pip install git+https://github.com/vlouvet/netsuite.git -With Web Services SOAP API support: +With Web Services SOAP API support (deprecated by NetSuite as of the 2027.1 release): - pip install netsuite[soap_api] + pip install "netsuite[soap_api] @ git+https://github.com/vlouvet/netsuite.git" With CLI support: - pip install netsuite[cli] + pip install "netsuite[cli] @ git+https://github.com/vlouvet/netsuite.git" With `orjson` package (faster JSON handling): - pip install netsuite[orjson] + pip install "netsuite[orjson] @ git+https://github.com/vlouvet/netsuite.git" With all features: - pip install netsuite[all] + pip install "netsuite[all] @ git+https://github.com/vlouvet/netsuite.git" + + +## Authentication + +The library supports three authentication methods: + +| Auth class | Mechanism | When to use | +|---|---|---| +| `OAuth2ClientCredentialsAuth` | OAuth 2.0 Client Credentials w/ JWT Bearer (M2M) | New server-to-server integrations. **Recommended.** | +| `OAuth2AccessTokenAuth` | Bring-your-own access token | When an upstream auth flow (e.g. Authorization Code in your web app) already has a token. | +| `TokenAuth` | OAuth 1.0a Token-Based Auth (TBA) | Legacy. Still works, but new integrations should use OAuth 2.0. | + +> **Note on SOAP.** NetSuite has announced that SOAP Web Services will be removed in the **2027.1 release**. Plan REST + OAuth 2.0 migrations accordingly. Instantiating `NetSuiteSoapApi` emits a `DeprecationWarning` to surface this at runtime. + +### OAuth 2.0 — Client Credentials (M2M, JWT Bearer) + +Upload a public key/certificate to your NetSuite integration record, save the certificate ID NetSuite gives you, and supply the matching private key: + +```python +from netsuite import Config, NetSuite, OAuth2ClientCredentialsAuth + +config = Config( + account="123456_SB1", + auth=OAuth2ClientCredentialsAuth( + client_id="", + certificate_id="", + private_key_pem=open("/path/to/private.pem").read(), + scope=["rest_webservices"], # default; can also include "restlets" or "suite_analytics" + algorithm="PS256", # default; "RS256", "ES256/384/512" also accepted + ), +) + +ns = NetSuite(config) +result = await ns.rest_api.get("/record/v1/customer/1337") +``` + +The library transparently mints a JWT, exchanges it at NetSuite's token endpoint, caches the access token until ~1 minute before expiry, and refreshes automatically. No additional code on your side. + +### OAuth 2.0 — Authorization Code Grant + +Best for apps acting on behalf of a NetSuite user. The library provides helpers for the URL building and token exchange; the redirect dance is yours. + +```python +from netsuite.oauth2 import ( + build_authorization_url, + exchange_authorization_code, +) + +# 1. Send the user here: +auth_url = build_authorization_url( + "123456_SB1", + client_id="", + redirect_uri="https://app.example.com/oauth/callback", + scope=["rest_webservices"], + state="", +) + +# 2. NetSuite redirects to your callback with `?code=...&state=...`. +# After verifying state, exchange the code: +token = await exchange_authorization_code( + "123456_SB1", + code=request.args["code"], + client_id="", + client_secret="", # OR pass certificate_id+private_key_pem + redirect_uri="https://app.example.com/oauth/callback", +) + +# 3. Pass the token into the library: +config = Config( + account="123456_SB1", + auth=OAuth2AccessTokenAuth( + access_token=token.access_token, + refresh_token=token.refresh_token, + expires_at=token.expires_at, + ), +) +``` + +`OAuth2AccessTokenAuth` does *not* refresh automatically — your application is responsible for refreshing using the `refresh_token` and constructing a new `Config`. + +### Legacy OAuth 1.0a (TBA) +```python +from netsuite import Config, NetSuite, TokenAuth + +config = Config( + account="123456_SB1", + auth=TokenAuth( + consumer_key="...", consumer_secret="...", + token_id="...", token_secret="...", + ), +) +``` + +Still fully supported, but consider migrating: NetSuite is steering integrations toward OAuth 2.0. ## Programmatic use - Basic Example @@ -122,6 +218,27 @@ async def async_main() -> dict: asyncio.run(async_main()) ``` +## Programmatic use - SuiteQL pagination + +NetSuite caps a single SuiteQL response at `limit=1000` rows, and a single query overall at 100,000 rows. To stream every page of a result set without manually wiring up the `next` link, use `suiteql_paginated`: + +```python +async def fetch_all_transactions(): + rows = [] + async for page in ns.rest_api.suiteql_paginated( + q="SELECT id, tranid FROM transaction", + limit=1000, + ): + rows.extend(page["items"]) + return rows +``` + +Each yielded `page` is the raw NetSuite response dict (with `items`, `count`, `hasMore`, `links`, …). Iteration stops automatically when `hasMore` is False. + +To retrieve more than 100,000 rows from a query, partition by a WHERE clause and run multiple paginated queries — for example by `id` ranges or date windows. + +> **`ORDER BY` caveat.** NetSuite has a known quirk where a SuiteQL query with `ORDER BY` and a small `limit` (the default 10) can return zero items. Either request a larger page (`limit=1000`) or sort client-side after fetching. See [#29](https://github.com/jacobsvante/netsuite/issues/29). + ## Programmatic use - Download Large Files Using SOAP API When working with large files, you might find that responses are truncated if they exceed 10MB. This limitation stems from the default settings in Zeep. To overcome this, enable the `xml_huge_tree` option in the Zeep client settings. diff --git a/mkdocs.yml b/mkdocs.yml index 15b8e05..29393f7 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,7 +1,7 @@ site_name: netsuite -repo_name: jacobsvante/netsuite -repo_url: https://github.com/jacobsvante/netsuite +repo_name: vlouvet/netsuite +repo_url: https://github.com/vlouvet/netsuite theme: name: material diff --git a/netsuite/cli/misc.py b/netsuite/cli/misc.py index 41cda27..08df477 100644 --- a/netsuite/cli/misc.py +++ b/netsuite/cli/misc.py @@ -1,4 +1,4 @@ -import pkg_resources +from importlib.metadata import version as _package_version __all__ = () @@ -10,4 +10,4 @@ def add_parser(parser, subparser): def version() -> str: - return pkg_resources.get_distribution("netsuite").version + return _package_version("netsuite") diff --git a/netsuite/config.py b/netsuite/config.py index 437f1ed..493cc50 100644 --- a/netsuite/config.py +++ b/netsuite/config.py @@ -7,10 +7,23 @@ from .constants import DEFAULT_INI_PATH, DEFAULT_INI_SECTION -__all__ = ("Config", "TokenAuth", "UsernamePasswordAuth") +__all__ = ( + "Config", + "OAuth2AccessTokenAuth", + "OAuth2ClientCredentialsAuth", + "TokenAuth", + "UsernamePasswordAuth", +) class TokenAuth(BaseModel): + """Legacy OAuth 1.0a Token-Based Authentication (TBA). + + NetSuite continues to support TBA, but OAuth 2.0 is the recommended + authentication method for new integrations. See + `OAuth2ClientCredentialsAuth` for the M2M replacement. + """ + consumer_key: str consumer_secret: str token_id: str @@ -28,11 +41,47 @@ class UsernamePasswordAuth(BaseModel): password: str +class OAuth2ClientCredentialsAuth(BaseModel): + """OAuth 2.0 Client Credentials with JWT Bearer assertion (M2M). + + The integration uploads a public certificate to NetSuite once, and + then signs short-lived JWT assertions with the matching private key + to mint access tokens. No browser, no user interaction. + """ + + client_id: str + certificate_id: str + private_key_pem: str + scope: t.List[str] = ["rest_webservices"] + algorithm: str = "PS256" + + +class OAuth2AccessTokenAuth(BaseModel): + """Bring-your-own access token. + + For when an upstream auth flow (e.g. Authorization Code Grant + handled in your web app) already produced a valid access token and + you just want this library to use it. Optional `refresh_token` and + `expires_at` are accepted but the library will not refresh + automatically — wire that into the upstream flow. + """ + + access_token: str + refresh_token: t.Optional[str] = None + expires_at: t.Optional[float] = None + + +_AnyAuth = t.Union[ + TokenAuth, + OAuth2ClientCredentialsAuth, + OAuth2AccessTokenAuth, + UsernamePasswordAuth, +] + + class Config(BaseModel): account: str - auth: t.Union[TokenAuth, UsernamePasswordAuth] - # TODO: Support OAuth2 - # auth: Union[OAuth2, TokenAuth] + auth: _AnyAuth log_level: t.Optional[str] = None @@ -43,6 +92,12 @@ class Config(BaseModel): def is_token_auth(self) -> bool: return isinstance(self.auth, TokenAuth) + @property + def is_oauth2_auth(self) -> bool: + return isinstance( + self.auth, (OAuth2ClientCredentialsAuth, OAuth2AccessTokenAuth) + ) + @property def is_sandbox(self) -> bool: return re.search(r"_SB[\d]+$", self.account) is not None diff --git a/netsuite/json.py b/netsuite/json.py index 06ef74e..c5e265c 100644 --- a/netsuite/json.py +++ b/netsuite/json.py @@ -35,8 +35,7 @@ def _orjson_default(obj: Any) -> Any: if isinstance(obj, str): return str(obj) else: - encoder = _get_encoder(obj) - return encoder(obj) + return _get_encoder(obj) def _isoformat(o: Union[datetime.date, datetime.time]) -> str: @@ -44,6 +43,10 @@ def _isoformat(o: Union[datetime.date, datetime.time]) -> str: def _get_encoder(obj: Any) -> Any: + """Return the encoded form of `obj` by walking its MRO for a registered + encoder. The function's name is a misnomer kept for backwards + compatibility — it returns the *encoded value*, not the encoder callable. + """ for base in obj.__class__.__mro__[:-1]: try: encoder = _ENCODERS_BY_TYPE[base] diff --git a/netsuite/oauth2.py b/netsuite/oauth2.py new file mode 100644 index 0000000..393478e --- /dev/null +++ b/netsuite/oauth2.py @@ -0,0 +1,368 @@ +"""OAuth 2.0 support for NetSuite. + +NetSuite is gradually removing SOAP Web Services (2025.2 is the last +planned SOAP endpoint, with older endpoints losing support over the +following releases). The recommended path forward is the REST API +authenticated via OAuth 2.0. +This module implements the two flows that matter for a backend library: + +* **Client Credentials with JWT Bearer Assertion** (RFC 7523 — *machine- + to-machine*). The integration uploads a public key/cert to NetSuite, + signs a short-lived JWT with the matching private key, and exchanges + it for an access token at NetSuite's token endpoint. No browser, no + user interaction. This is what most server-to-server integrations + should use. + +* **Authorization Code Grant** — interactive. We provide the helper + functions to build the authorization URL and exchange the resulting + authorization code for tokens, but the redirect dance itself is left + to the calling application (a Flask/FastAPI app, a CLI tool, etc.) — + the library has no opinion on how you serve the redirect URI. + +For both flows the resulting access token is plugged into a single +``OAuth2BearerAuth`` httpx auth handler that the REST API and Restlet +clients use transparently in place of the legacy OAuth 1.0a token-based +auth. + +Reference: NetSuite OAuth 2.0 documentation, "Issue Token and Revoke +Token REST Services" and "OAuth 2.0 for Integration Application +Authentication". +""" + +import time +from dataclasses import dataclass, field +from typing import Awaitable, Callable, Iterable, List, Optional + +import httpx +from joserfc import jwt +from joserfc.jwk import ECKey, RSAKey + +from . import json as nsjson + +__all__ = ( + "DEFAULT_SCOPES", + "JWT_BEARER_ASSERTION_TYPE", + "OAuth2BearerAuth", + "OAuth2Token", + "build_authorization_url", + "build_client_assertion", + "build_token_endpoint", + "exchange_authorization_code", + "exchange_client_assertion", +) + + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +JWT_BEARER_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" +DEFAULT_SCOPES: tuple = ("rest_webservices",) +# The NetSuite-recommended algorithm for the client assertion. PS256 is +# RSA-PSS w/ SHA-256; ES256 is ECDSA on P-256 w/ SHA-256. Both are +# accepted by the token endpoint. +SUPPORTED_ALGORITHMS: tuple = ("PS256", "RS256", "ES256", "ES384", "ES512") +DEFAULT_ALGORITHM = "PS256" + +# How long before expiry we treat a token as already expired and refresh. +# NetSuite's access tokens last ~1 hour; 60 s of slack is plenty. +_EXPIRY_SAFETY_MARGIN_SECONDS = 60 + + +# --------------------------------------------------------------------------- +# URL builders +# --------------------------------------------------------------------------- + + +def _account_slug(account: str) -> str: + """Replicates `Config.account_slugified` without importing it (which + would create a circular dependency for callers that pass a string).""" + return account.lower().replace("_", "-") + + +def build_token_endpoint(account: str) -> str: + """The OAuth 2.0 token endpoint for a given NetSuite account.""" + return ( + f"https://{_account_slug(account)}" + ".suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/token" + ) + + +def build_authorization_url( + account: str, + *, + client_id: str, + redirect_uri: str, + scope: Iterable[str] = DEFAULT_SCOPES, + state: Optional[str] = None, +) -> str: + """Build the URL the user should be redirected to in their browser + to start the Authorization Code Grant flow. + + The calling app is responsible for serving ``redirect_uri`` and + extracting the ``code`` query parameter, which is then handed to + :func:`exchange_authorization_code`. + """ + base = ( + f"https://{_account_slug(account)}" + ".app.netsuite.com/app/login/oauth2/authorize.nl" + ) + params = { + "response_type": "code", + "client_id": client_id, + "redirect_uri": redirect_uri, + "scope": " ".join(scope), + } + if state is not None: + params["state"] = state + return str(httpx.URL(base).copy_with(params=params)) + + +# --------------------------------------------------------------------------- +# JWT client assertion +# --------------------------------------------------------------------------- + + +def _load_signing_key(private_key_pem: str, algorithm: str): + """Load a PEM-encoded private key into the right joserfc JWK type for + the chosen algorithm. RS*/PS* algorithms need RSA; ES* need EC.""" + family = algorithm[:2].upper() + if family in ("RS", "PS"): + return RSAKey.import_key(private_key_pem) + if family == "ES": + return ECKey.import_key(private_key_pem) + raise ValueError( + f"Unsupported algorithm '{algorithm}'. " + f"Expected one of {SUPPORTED_ALGORITHMS}." + ) + + +def build_client_assertion( + account: str, + *, + client_id: str, + certificate_id: str, + private_key_pem: str, + scope: Iterable[str] = DEFAULT_SCOPES, + algorithm: str = DEFAULT_ALGORITHM, + now: Optional[int] = None, + ttl_seconds: int = 3600, +) -> str: + """Build and sign a JWT to use as the ``client_assertion`` in a + Client Credentials token request. + + ``certificate_id`` is the ``kid`` NetSuite assigns when you upload + the matching public key/certificate. ``private_key_pem`` is the + matching private key in PEM format. + + NetSuite caps assertion ``exp`` at one hour; ``ttl_seconds`` is + clamped to that ceiling. + """ + if algorithm not in SUPPORTED_ALGORITHMS: + raise ValueError( + f"Unsupported algorithm '{algorithm}'. " + f"Expected one of {SUPPORTED_ALGORITHMS}." + ) + issued_at = int(now if now is not None else time.time()) + expires_at = issued_at + min(ttl_seconds, 3600) + header = {"alg": algorithm, "typ": "JWT", "kid": certificate_id} + claims = { + "iss": client_id, + "scope": list(scope), + "aud": build_token_endpoint(account), + "iat": issued_at, + "exp": expires_at, + } + key = _load_signing_key(private_key_pem, algorithm) + # joserfc's default registry only permits its "recommended" algorithms + # (RS256/ES256/...), and rejects PS256 — which is this module's default, + # and NetSuite's recommended assertion algorithm. Explicitly whitelist the + # chosen algorithm so every entry in SUPPORTED_ALGORITHMS actually signs. + return jwt.encode(header, claims, key, algorithms=[algorithm]) + + +# --------------------------------------------------------------------------- +# Token exchanges +# --------------------------------------------------------------------------- + + +@dataclass +class OAuth2Token: + """The result of any successful token exchange. + + NetSuite returns ``access_token`` plus ``expires_in`` (seconds) for + Client Credentials, and adds ``refresh_token`` for Authorization + Code Grant. ``expires_at`` is computed locally so callers can decide + whether to refresh. + """ + + access_token: str + token_type: str = "Bearer" + expires_at: float = 0.0 + refresh_token: Optional[str] = None + scope: List[str] = field(default_factory=list) + + @classmethod + def from_response( + cls, payload: dict, *, now: Optional[float] = None + ) -> "OAuth2Token": + issued = now if now is not None else time.time() + scope = payload.get("scope", "") + if isinstance(scope, str): + scope = scope.split() + return cls( + access_token=payload["access_token"], + token_type=payload.get("token_type", "Bearer"), + expires_at=issued + float(payload.get("expires_in", 0)), + refresh_token=payload.get("refresh_token"), + scope=list(scope), + ) + + def is_expired(self, *, now: Optional[float] = None) -> bool: + when = now if now is not None else time.time() + return when >= (self.expires_at - _EXPIRY_SAFETY_MARGIN_SECONDS) + + +async def _post_token( + url: str, + data: dict, + *, + timeout: float = 30.0, +) -> OAuth2Token: + async with httpx.AsyncClient(timeout=timeout) as client: + resp = await client.post( + url, + data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + if resp.status_code >= 400: + # Surface the server-side error message rather than just the status. + raise httpx.HTTPStatusError( + f"NetSuite token endpoint returned {resp.status_code}: {resp.text}", + request=resp.request, + response=resp, + ) + return OAuth2Token.from_response(nsjson.loads(resp.text)) + + +async def exchange_client_assertion( + account: str, + *, + client_id: str, + certificate_id: str, + private_key_pem: str, + scope: Iterable[str] = DEFAULT_SCOPES, + algorithm: str = DEFAULT_ALGORITHM, +) -> OAuth2Token: + """Run the Client Credentials + JWT Bearer flow end-to-end and + return a fresh access token.""" + assertion = build_client_assertion( + account, + client_id=client_id, + certificate_id=certificate_id, + private_key_pem=private_key_pem, + scope=scope, + algorithm=algorithm, + ) + return await _post_token( + build_token_endpoint(account), + { + "grant_type": "client_credentials", + "client_assertion_type": JWT_BEARER_ASSERTION_TYPE, + "client_assertion": assertion, + }, + ) + + +async def exchange_authorization_code( + account: str, + *, + code: str, + client_id: str, + redirect_uri: str, + client_secret: Optional[str] = None, + certificate_id: Optional[str] = None, + private_key_pem: Optional[str] = None, + algorithm: str = DEFAULT_ALGORITHM, +) -> OAuth2Token: + """Exchange an authorization code for an access + refresh token. + + NetSuite supports two ways to authenticate the code-exchange request: + a shared ``client_secret`` (basic auth) or the same JWT bearer + assertion used by Client Credentials. Pass whichever your integration + is configured for. Exactly one must be provided. + """ + has_secret = client_secret is not None + has_assertion = certificate_id is not None and private_key_pem is not None + if has_secret == has_assertion: + raise ValueError( + "Provide exactly one of `client_secret` or " + "(`certificate_id` + `private_key_pem`) for the code exchange." + ) + + data = { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirect_uri, + } + if has_secret: + data["client_id"] = client_id + data["client_secret"] = client_secret # type: ignore[assignment] + else: + assertion = build_client_assertion( + account, + client_id=client_id, + certificate_id=certificate_id, # type: ignore[arg-type] + private_key_pem=private_key_pem, # type: ignore[arg-type] + algorithm=algorithm, + ) + data["client_assertion_type"] = JWT_BEARER_ASSERTION_TYPE + data["client_assertion"] = assertion + + return await _post_token(build_token_endpoint(account), data) + + +# --------------------------------------------------------------------------- +# httpx.Auth subclass +# --------------------------------------------------------------------------- + + +class OAuth2BearerAuth(httpx.Auth): + """An httpx auth handler that sets ``Authorization: Bearer ``. + + When the token is missing or close to expiry, ``token_factory`` is + awaited to mint a fresh one. Callers compose the factory: e.g. for + Client Credentials, partial-apply :func:`exchange_client_assertion`; + for a bring-your-own token, return a static ``OAuth2Token``. + + The class is async-first because the rest of this library is. We + don't expose a sync flow — there isn't one anywhere else either. + """ + + requires_response_body = False + + def __init__( + self, + token_factory: Callable[[], Awaitable[OAuth2Token]], + *, + initial_token: Optional[OAuth2Token] = None, + ) -> None: + self._token_factory = token_factory + self._token: Optional[OAuth2Token] = initial_token + + @property + def token(self) -> Optional[OAuth2Token]: + """The currently cached token, if any. Mostly useful in tests.""" + return self._token + + def sync_auth_flow(self, request): # pragma: no cover - not supported + raise RuntimeError( + "OAuth2BearerAuth is async-only. Use httpx.AsyncClient " + "(which the rest of this library does)." + ) + + async def async_auth_flow(self, request): + if self._token is None or self._token.is_expired(): + self._token = await self._token_factory() + request.headers["Authorization"] = f"Bearer {self._token.access_token}" + yield request diff --git a/netsuite/rest_api.py b/netsuite/rest_api.py index 10cde84..5ad20d4 100644 --- a/netsuite/rest_api.py +++ b/netsuite/rest_api.py @@ -1,14 +1,48 @@ import logging +import re from functools import cached_property -from typing import Sequence +from typing import Any, AsyncIterator, Dict, Optional, Sequence, Union -from . import rest_api_base +from . import json, rest_api_base from .config import Config +from .exceptions import NetsuiteAPIRequestError logger = logging.getLogger(__name__) __all__ = ("NetSuiteRestApi",) +# Custom media types introduced for the 2026.1 REST web services +# operations. Batch uses a `collection` content type; create-form and +# selectOptions select their behaviour through the `Accept` header. +_COLLECTION_MEDIA_TYPE = "application/vnd.oracle.resource+json; type=collection" +_CREATE_FORM_MEDIA_TYPE = "application/vnd.oracle.resource+json; type=create-form" +_SELECT_OPTIONS_MEDIA_TYPE = "application/vnd.oracle.resource+json; type=select-options" + +# Batch add/update/upsert verbs (GET/DELETE batches go through the normal +# get()/delete() with an `ids` param instead). +_BATCH_METHODS = ("POST", "PATCH", "PUT") + + +def _next_link(suiteql_response: Dict[str, Any]) -> Optional[str]: + """Return the absolute URL of the `next` link from a SuiteQL response, or None.""" + if not suiteql_response.get("hasMore"): + return None + for link in suiteql_response.get("links") or (): + if link.get("rel") == "next": + return link.get("href") + return None + + +# Matches an `ORDER BY` clause (case-insensitive). Word boundaries prevent +# false positives on column names like `order_by_id`. The heuristic +# deliberately fires on subquery ORDER BY's too, since those can also +# surface the NetSuite empty-result quirk. +_ORDER_BY_RE = re.compile(r"\border\s+by\b", re.IGNORECASE) + +# Below this threshold, `ORDER BY` is at risk of NetSuite's +# zero-row quirk (jacobsvante/netsuite#29). +_ORDER_BY_SAFE_LIMIT = 1000 + class NetSuiteRestApi(rest_api_base.RestApiBase): def __init__( @@ -53,13 +87,33 @@ async def delete(self, subpath: str, **request_kw): # TODO maybe break out params vs poping? async def suiteql(self, q: str, limit: int = 10, offset: int = 0, **request_kw): """ + Run a single SuiteQL query. + Example: >>> suiteql(q="SELECT * FROM Transaction", limit=10, offset=0) + Note on `ORDER BY`: NetSuite has a known quirk where a SuiteQL query + with `ORDER BY` and a small `limit` (the default 10) can return zero + items. If you hit this, request a larger page (`limit=1000`) or sort + client-side after fetching. This method also logs a warning when it + detects the pattern. See jacobsvante/netsuite#29. + + Note on pagination: NetSuite caps `limit` at 1000. To stream every + page until exhaustion, use `suiteql_paginated` instead. + Documentation: - https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_156257799794.html#Using-SuiteQL """ + if _ORDER_BY_RE.search(q) and limit < _ORDER_BY_SAFE_LIMIT: + logger.warning( + "SuiteQL query contains `ORDER BY` with limit=%d. NetSuite " + "has a known quirk where this combination can return zero " + "rows. Consider raising limit to %d or sorting client-side. " + "See jacobsvante/netsuite#29.", + limit, + _ORDER_BY_SAFE_LIMIT, + ) return await self._request( "POST", "/query/v1/suiteql", @@ -70,6 +124,63 @@ async def suiteql(self, q: str, limit: int = 10, offset: int = 0, **request_kw): **request_kw, ) + async def suiteql_paginated( + self, + q: str, + *, + limit: int = 1000, + offset: int = 0, + **request_kw, + ) -> AsyncIterator[Dict[str, Any]]: + """ + Async generator yielding each page of a SuiteQL query, following the + `next` link in NetSuite's response until `hasMore` is False. + + Yields the raw page dict (with `items`, `count`, `hasMore`, `links`, + etc.). Use `limit=1000` (the NetSuite max) to minimize round trips. + + Example: + >>> async for page in rest_api.suiteql_paginated(q="SELECT id FROM transaction"): + ... for row in page["items"]: + ... ... + + Caveat: a single SuiteQL query can return at most 100,000 rows in + total — that is a NetSuite-side cap, not a library limitation. To + retrieve more, partition the query with a WHERE clause (e.g. on + `id` ranges or date windows) and run several paginated queries. + See jacobsvante/netsuite#42. + """ + # First page goes through the normal `suiteql` path so users get + # consistent header/param handling. Subsequent pages follow the + # absolute `next` link from each response. + page = await self.suiteql(q, limit=limit, offset=offset, **request_kw) + yield page + + next_url = _next_link(page) + # Subsequent pages reuse the same body; only the URL (with offset) + # changes. We forward `**request_kw` so callers' headers/params + # still apply. + body_kw = { + "headers": {"Prefer": "transient", **request_kw.pop("headers", {})}, + "json": {"q": q, **request_kw.pop("json", {})}, + } + # `params` are encoded in the next URL, so we must not also pass + # them here — that would double-up offset/limit. + request_kw.pop("params", None) + + while next_url is not None: + page = await self._request( + "POST", + # `subpath` is ignored when `url` is provided, but + # `_request_impl` still requires the parameter. + "/query/v1/suiteql", + url=next_url, + **body_kw, + **request_kw, + ) + yield page + next_url = _next_link(page) + async def jsonschema(self, record_type: str, **request_kw): headers = { "Accept": "application/schema+json", @@ -133,6 +244,260 @@ async def openapi(self, record_types: Sequence[str] = (), **request_kw): **request_kw, ) + async def attach( + self, + record_type: str, + record_id: str, + target_type: str, + target_id: str, + *, + role: Optional[Dict[str, Any]] = None, + **request_kw, + ): + """ + Attach one record instance to another, defining a relationship + between them (new in NetSuite 2026.1 REST web services). + + `record_type`/`record_id` identify the record being attached *to*; + `target_type`/`target_id` identify the record being attached. Both + IDs may be internal IDs or external IDs in `eid:VALUE` form. + + NetSuite currently supports attaching contact and file records only. + When attaching a contact you may pass `role` (e.g. `{"id": "-10"}` + or `{"externalId": "family"}`); otherwise the request body is empty. + + Returns `None` (NetSuite responds with HTTP 204 No Content). + + https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/article_0113084334.html + """ + json_body = request_kw.pop("json", {}) + if role is not None: + json_body = {"role": role, **json_body} + return await self._request( + "POST", + f"/record/v1/{record_type}/{record_id}/!attach/{target_type}/{target_id}", + json=json_body, + **request_kw, + ) + + async def detach( + self, + record_type: str, + record_id: str, + target_type: str, + target_id: str, + **request_kw, + ): + """ + Remove the relationship between two record instances (the inverse of + `attach`). IDs may be internal IDs or `eid:VALUE` external IDs. + + Returns `None` (HTTP 204 No Content). + + https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/article_0113084334.html + """ + return await self._request( + "POST", + f"/record/v1/{record_type}/{record_id}/!detach/{target_type}/{target_id}", + **request_kw, + ) + + async def create_form( + self, + record_type: str, + record_id: str, + target_type: str, + *, + body: Optional[Dict[str, Any]] = None, + **request_kw, + ): + """ + Run the create-form (transform) operation: load a target record with + its fields prepopulated from a related source record, without + submitting it (new in NetSuite 2026.1 REST web services). + + For example, transform a sales order into an item fulfillment to see + every default field and default line ID before you POST the new + record. Pass field overrides in `body`; the operation supports the + `expand`, `expandSubResources` and `fields` query params via + `params`. + + Returns the prepopulated record as a dict. + + https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/article_1217103046.html + """ + headers = { + "Accept": _CREATE_FORM_MEDIA_TYPE, + **request_kw.pop("headers", {}), + } + return await self._request( + "POST", + f"/record/v1/{record_type}/{record_id}/!transform/{target_type}", + headers=headers, + json=body if body is not None else {}, + **request_kw, + ) + + async def select_options( + self, + record_type: str, + fields: Union[str, Sequence[str]], + *, + record_id: Optional[str] = None, + body: Optional[Dict[str, Any]] = None, + **request_kw, + ): + """ + Retrieve the valid select options for one or more fields on a record + (new in NetSuite 2026.1 REST web services). + + Pass a single field name or a sequence of them in `fields` (sublist + fields use dotted names, e.g. `line.dueToFromSubsidiary`). Omit + `record_id` to get the options on a *new* record instance (issued as + a POST); pass `record_id` to query an *existing* instance (issued as + a PATCH). When the options depend on other field values, supply those + in `body` (e.g. `{"subsidiary": {"id": 1}}`). + + Returns the response dict, with a `_selectOptions` block per requested + field (each itself paginated: `items`/`count`/`hasMore`/ + `totalResults`). + + https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/article_0115100241.html + """ + if isinstance(fields, str): + fields = [fields] + headers = { + "Accept": _SELECT_OPTIONS_MEDIA_TYPE, + **request_kw.pop("headers", {}), + } + params = {"fields": ",".join(fields), **request_kw.pop("params", {})} + if record_id is None: + method, subpath = "POST", f"/record/v1/{record_type}" + else: + method, subpath = "PATCH", f"/record/v1/{record_type}/{record_id}" + return await self._request( + method, + subpath, + headers=headers, + params=params, + json=body if body is not None else {}, + **request_kw, + ) + + async def batch( + self, + record_type: str, + items: Sequence[Dict[str, Any]], + *, + method: str = "POST", + idempotency_key: Optional[str] = None, + **request_kw, + ): + """ + Add, update, or upsert up to 100 instances of a single record type in + one asynchronous request (new in NetSuite 2026.1 REST web services). + + `method` is POST (create), PUT (upsert), or PATCH (update). Each item + in `items` is a record body; PATCH/PUT items must carry an `id` or + `externalId`. Pass `idempotency_key` to set the + `X-NetSuite-idempotency-key` header. + + NetSuite processes the batch asynchronously, so this returns a dict + with the HTTP `status_code`, the `location` URL of the async job + (poll it with `get()`), and any response `body`. Unlike the other + helpers it talks to the lower-level request layer directly, because + the async job URL is only exposed in the `Location` response header, + which the JSON helpers discard. + + For batch reads or deletes, use `get()`/`delete()` on the collection + endpoint with an `ids` param instead. + + https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/article_0127092747.html + """ + method = method.upper() + if method not in _BATCH_METHODS: + raise ValueError( + f"batch() method must be one of {_BATCH_METHODS}, got " + f"{method!r}. For batch reads/deletes use get()/delete() with " + "an `ids` param." + ) + headers = { + "Prefer": "respond-async", + "Content-Type": _COLLECTION_MEDIA_TYPE, + **request_kw.pop("headers", {}), + } + if idempotency_key is not None: + headers.setdefault("X-NetSuite-idempotency-key", idempotency_key) + resp = await self._request_impl( + method, + f"/record/v1/{record_type}", + headers=headers, + json={"items": list(items), **request_kw.pop("json", {})}, + **request_kw, + ) + if resp.status_code < 200 or resp.status_code > 299: + raise NetsuiteAPIRequestError(resp.status_code, resp.text) + body = None + if resp.text: + try: + body = json.loads(resp.text) + except Exception: + body = None + return { + "status_code": resp.status_code, + "location": resp.headers.get("Location"), + "body": body, + } + + async def create_record( + self, + record_type: str, + record_data: Dict[str, Any], + **request_kw, + ) -> Union[int, str, None]: + """ + Create a record and return the internal ID NetSuite assigns to it. + + POSTs `record_data` to `/record/v1/{record_type}`. NetSuite answers a + successful create with HTTP 204 No Content and a `Location` header + pointing at the new record; this parses the trailing ID segment and + returns it as an `int` when numeric, or the raw string for external + IDs (`eid:...`). Returns `None` if NetSuite omits the `Location` + header. + + Talks to the lower-level request layer directly (like `batch`) + because the new record's ID is only exposed in the `Location` + response header, which the JSON helper `_request` discards. + + https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_1545141395.html + """ + resp = await self._request_impl( + "POST", + f"/record/v1/{record_type}", + json=record_data, + **request_kw, + ) + if resp.status_code < 200 or resp.status_code > 299: + raise NetsuiteAPIRequestError(resp.status_code, resp.text) + return self._parse_id_from_location(resp.headers.get("Location")) + + @staticmethod + def _parse_id_from_location(location: Optional[str]) -> Union[int, str, None]: + """Extract the record ID (last path segment) from a `Location` URL, + ignoring any query string or fragment and tolerating a trailing + slash. Returns an `int` for numeric IDs, the raw string otherwise, + or `None` when no usable segment is present.""" + if not location: + return None + path = location.split("?", 1)[0].split("#", 1)[0].rstrip("/") + record_id = path.rsplit("/", 1)[-1] + if not record_id: + return None + try: + return int(record_id) + except ValueError: + return record_id + def _make_hostname(self): return f"{self._config.account_slugified}.suitetalk.api.netsuite.com" diff --git a/netsuite/rest_api_base.py b/netsuite/rest_api_base.py index 23dc43f..46afcb5 100644 --- a/netsuite/rest_api_base.py +++ b/netsuite/rest_api_base.py @@ -1,5 +1,7 @@ import asyncio +import functools import logging +import time from functools import cached_property import httpx @@ -9,7 +11,17 @@ from oauthlib.oauth1.rfc5849.signature import sign_hmac_sha256 from . import json +from .config import ( + OAuth2AccessTokenAuth, + OAuth2ClientCredentialsAuth, + TokenAuth, +) from .exceptions import NetsuiteAPIRequestError, NetsuiteAPIResponseParsingError +from .oauth2 import ( + OAuth2BearerAuth, + OAuth2Token, + exchange_client_assertion, +) __all__ = ("RestApiBase",) @@ -91,14 +103,56 @@ def _make_url(self, subpath: str): def _make_auth(self): auth = self._config.auth - return OAuth1Auth( - client_id=auth.consumer_key, - client_secret=auth.consumer_secret, - token=auth.token_id, - token_secret=auth.token_secret, - realm=self._config.account, - force_include_body=True, - signature_method=self._signature_method, + if isinstance(auth, TokenAuth): + return OAuth1Auth( + client_id=auth.consumer_key, + client_secret=auth.consumer_secret, + token=auth.token_id, + token_secret=auth.token_secret, + realm=self._config.account, + force_include_body=True, + signature_method=self._signature_method, + ) + if isinstance(auth, OAuth2ClientCredentialsAuth): + # The auth handler caches the token across calls; we lazily + # bind a `token_factory` that knows how to mint a fresh one. + token_factory = functools.partial( + exchange_client_assertion, + self._config.account, + client_id=auth.client_id, + certificate_id=auth.certificate_id, + private_key_pem=auth.private_key_pem, + scope=auth.scope, + algorithm=auth.algorithm, + ) + cached = getattr(self, "_oauth2_handler", None) + if cached is None: + cached = OAuth2BearerAuth(token_factory) + # Cache on the instance so token re-use works across + # back-to-back requests. + self._oauth2_handler = cached # type: ignore[attr-defined] + return cached + if isinstance(auth, OAuth2AccessTokenAuth): + # Bring-your-own token. We don't refresh; the user wired + # that in upstream. We still wrap it in OAuth2BearerAuth so + # the Authorization header is set consistently. + initial = OAuth2Token( + access_token=auth.access_token, + expires_at=auth.expires_at or (time.time() + 3600), + refresh_token=auth.refresh_token, + ) + + async def _no_refresh() -> OAuth2Token: + raise RuntimeError( + "OAuth2AccessTokenAuth does not refresh automatically. " + "Provide a fresh token via your own auth flow." + ) + + return OAuth2BearerAuth(_no_refresh, initial_token=initial) + raise TypeError( + f"Unsupported auth type for HTTP requests: {type(auth).__name__}. " + f"Use TokenAuth, OAuth2ClientCredentialsAuth, or " + f"OAuth2AccessTokenAuth." ) def _make_default_headers(self): diff --git a/netsuite/soap_api/client.py b/netsuite/soap_api/client.py index 0525b41..3f1da69 100644 --- a/netsuite/soap_api/client.py +++ b/netsuite/soap_api/client.py @@ -1,5 +1,6 @@ import logging import re +import warnings from contextlib import contextmanager from datetime import datetime from functools import cached_property @@ -15,8 +16,19 @@ __all__ = ("NetSuiteSoapApi",) +SOAP_DEPRECATION_MESSAGE = ( + "NetSuite has announced that SOAP Web Services are being gradually " + "removed. The 2025.2 endpoint is the last planned SOAP endpoint, and " + "older endpoints lose support over the following releases. New " + "integrations should use the REST API with OAuth 2.0 (see " + "`netsuite.OAuth2ClientCredentialsAuth`); existing SOAP integrations " + "should plan a migration. See NetSuite's SOAP Removal Plans FAQ for the " + "endpoint support timeline." +) + + class NetSuiteSoapApi: - version = "2021.1.0" + version = getattr(Config, "wsdl_version", "2024.2.0") wsdl_url_tmpl = "https://{account_slug}.suitetalk.api.netsuite.com/wsdl/v{underscored_version}/netsuite.wsdl" def __init__( @@ -28,6 +40,11 @@ def __init__( cache: Optional[zeep.cache.Base] = None, ) -> None: self._ensure_required_dependencies() + warnings.warn( + SOAP_DEPRECATION_MESSAGE, + DeprecationWarning, + stacklevel=2, + ) if version is not None: assert re.match(r"\d+\.\d+\.\d+", version) self.version = version diff --git a/netsuite/soap_api/decorators.py b/netsuite/soap_api/decorators.py index ee9e9f1..41fa440 100644 --- a/netsuite/soap_api/decorators.py +++ b/netsuite/soap_api/decorators.py @@ -1,3 +1,4 @@ +import inspect from functools import wraps from typing import Any, Callable, Optional @@ -8,6 +9,17 @@ __all__ = ("WebServiceCall",) +def _is_soap_response(response: Any) -> bool: + # SOAP responses returned by zeep are instances of `CompoundValue` + # (dynamically generated subclasses produced from the WSDL schema). + # The previous implementation checked `isinstance(response, + # zeep.xsd.ComplexType)` — but `ComplexType` is the *schema* definition + # class, not the runtime value class, so the check was always False and + # the decorator silently returned the raw response without status + # validation or extraction. See jacobsvante/netsuite#45. + return isinstance(response, zeep.xsd.CompoundValue) + + def WebServiceCall( path: Optional[str] = None, extract: Optional[Callable] = None, @@ -32,9 +44,17 @@ def WebServiceCall( def decorator(fn): @wraps(fn) - def wrapper(self, *args, **kw): + async def async_wrapper(self, *args, **kw): + response = await fn(self, *args, **kw) + return _process_response(response) + + @wraps(fn) + def sync_wrapper(self, *args, **kw): response = fn(self, *args, **kw) - if not isinstance(response, zeep.xsd.ComplexType): + return _process_response(response) + + def _process_response(response): + if not _is_soap_response(response): return response if path is not None: @@ -68,6 +88,8 @@ def wrapper(self, *args, **kw): return response - return wrapper + if inspect.iscoroutinefunction(fn): + return async_wrapper + return sync_wrapper return decorator diff --git a/pyproject.toml b/pyproject.toml index a73f9f4..d0efd37 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,12 +2,15 @@ name = "netsuite" version = "0.12.0" description = "Make async requests to NetSuite SuiteTalk SOAP/REST Web Services and Restlets" -authors = ["Jacob Magnusson ", "Mike Bianco "] +authors = [ + "Jacob Magnusson ", + "Mike Bianco ", + "Vicente Louvet III ", +] license = "MIT" readme = "README.md" -homepage = "https://jacobsvante.github.io/netsuite/" -repository = "https://github.com/jacobsvante/netsuite" -documentation = "https://jacobsvante.github.io/netsuite/" +homepage = "https://github.com/vlouvet/netsuite" +repository = "https://github.com/vlouvet/netsuite" classifiers = [ "Development Status :: 4 - Beta", "Environment :: Web Environment", @@ -25,7 +28,11 @@ classifiers = [ python = ">=3.9" authlib = ">=1,<3" # As per httpx recommendation we will lock to a fixed minor version until 1.0 is released -httpx = ">=0.25,<0.28" +httpx = ">=0.25,<0.29" +# Used directly by netsuite/oauth2.py for JWT signing. Newer authlib pulls +# this in transitively, but older authlib in our `>=1,<3` range does not, +# so declare it explicitly to keep CI installs deterministic. +joserfc = ">=1,<2" pydantic = "^2.4.2" orjson = { version = "~3", optional = true } ipython = { version = "~8", optional = true, python = "^3.9" } @@ -42,14 +49,15 @@ orjson = ["orjson"] all = ["zeep", "ipython", "orjson", "odbc"] [tool.poetry.dev-dependencies] -black = "~24" +black = "~25" flake8 = "~7" isort = "~6" mkdocs-material = "~9" mypy = ">=1,<3" pytest = "~8" -pytest-cov = "~5" -types-setuptools = "^75.8.2" +pytest-asyncio = "~0.23" +types-setuptools = "^80.9.0" +pytest-cov = "~6" types-requests = "^2.27.30" [tool.poetry.scripts] @@ -69,3 +77,11 @@ multi_line_output = 3 [tool.pytest.ini_options] markers = ["slow: marks tests as slow (deselect with '-m \"not slow\"')"] +asyncio_mode = "strict" +# The SOAP DeprecationWarning is intentional and tested explicitly in +# test_soap_api_deprecation. Silence it in every other test path so the +# pytest summary stays useful for surfacing real warnings. +filterwarnings = [ + "default", + "ignore:NetSuite has announced that SOAP Web Services:DeprecationWarning", +] diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..4494feb --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,145 @@ +"""Tests for the CLI surface — argparse wiring, version/help paths, and +config loading helpers. Does not invoke any real NetSuite calls.""" + +import argparse +import importlib +from unittest.mock import patch + +import pytest + +# `netsuite.cli` re-exports the `main` function, shadowing the submodule, so +# import the module object explicitly. +cli_main_module = importlib.import_module("netsuite.cli.main") +from netsuite.cli import helpers, misc # noqa: E402 +from netsuite.cli import rest_api as cli_rest_api # noqa: E402 +from netsuite.cli import restlet, soap_api # noqa: E402 + +# --------------------------------------------------------------------------- +# helpers.load_config_or_error +# --------------------------------------------------------------------------- + + +def test_load_config_or_error_returns_config(tmp_path): + ini = tmp_path / "ns.ini" + ini.write_text( + "[netsuite]\n" + "account = 999\n" + "consumer_key = ck\n" + "consumer_secret = cs\n" + "token_id = ti\n" + "token_secret = ts\n" + ) + parser = argparse.ArgumentParser() + config = helpers.load_config_or_error(parser, str(ini), "netsuite") + assert config.account == "999" + + +def test_load_config_or_error_missing_file_calls_parser_error(tmp_path): + parser = argparse.ArgumentParser() + with pytest.raises(SystemExit): + helpers.load_config_or_error(parser, str(tmp_path / "missing.ini"), "x") + + +def test_load_config_or_error_missing_section_calls_parser_error(tmp_path): + ini = tmp_path / "ns.ini" + ini.write_text( + "[netsuite]\n" + "account = 999\n" + "consumer_key = ck\n" + "consumer_secret = cs\n" + "token_id = ti\n" + "token_secret = ts\n" + ) + parser = argparse.ArgumentParser() + with pytest.raises(SystemExit): + helpers.load_config_or_error(parser, str(ini), "missing-section") + + +# --------------------------------------------------------------------------- +# misc — version +# --------------------------------------------------------------------------- + + +def test_misc_version_returns_a_string(): + out = misc.version() + # Doesn't matter what version exactly — just that it's a non-empty string. + assert isinstance(out, str) and out + + +def test_misc_add_parser_registers_version_subcommand(): + parser = argparse.ArgumentParser() + sub = parser.add_subparsers() + version_parser, _ = misc.add_parser(parser, sub) + args = parser.parse_args(["version"]) + assert args.func is misc.version + + +# --------------------------------------------------------------------------- +# Subparser registration — build the full CLI tree without executing it. +# This covers argparse wiring in restlet/rest_api/soap_api. +# --------------------------------------------------------------------------- + + +def _build_full_parser(): + parser = argparse.ArgumentParser() + sub = parser.add_subparsers() + misc.add_parser(parser, sub) + restlet.add_parser(parser, sub) + cli_rest_api.add_parser(parser, sub) + soap_api.add_parser(parser, sub) + return parser + + +def test_full_cli_parser_builds_without_error(): + _build_full_parser() + + +def test_restlet_get_subcommand_parses_required_args(): + parser = _build_full_parser() + args = parser.parse_args(["restlet", "get", "42"]) + assert args.script_id == 42 + assert args.deploy == 1 # default + + +def test_restlet_get_accepts_custom_deploy(): + parser = _build_full_parser() + args = parser.parse_args(["restlet", "get", "42", "-d", "7"]) + assert args.deploy == 7 + + +def test_restlet_post_requires_payload_file(tmp_path): + parser = _build_full_parser() + payload = tmp_path / "payload.json" + payload.write_text('{"k": "v"}') + args = parser.parse_args(["restlet", "post", "42", str(payload)]) + assert args.script_id == 42 + # FileType opens the file for reading. + with args.payload_file as fh: + assert fh.read() == '{"k": "v"}' + + +# --------------------------------------------------------------------------- +# main() — the no-argv help path. We avoid actually invoking commands. +# --------------------------------------------------------------------------- + + +def test_main_with_no_args_prints_help_and_returns(capsys): + """argparse exits with code 2 when a required subparser is missing. + `main()` catches the resulting SystemExit-via-Exception in some Python + versions; in others it bubbles through as SystemExit. Either way the + process must not raise an unrelated exception.""" + with patch("sys.argv", ["netsuite"]): + try: + cli_main_module.main() + except SystemExit: + # argparse can call sys.exit on missing required subcommand; + # that's expected behavior. + pass + + +def test_main_handles_version_subcommand(capsys): + with patch("sys.argv", ["netsuite", "version"]): + cli_main_module.main() + captured = capsys.readouterr() + # `version` was invoked and its return value printed. + assert captured.out.strip() diff --git a/tests/test_cli_main.py b/tests/test_cli_main.py new file mode 100644 index 0000000..d4ecec3 --- /dev/null +++ b/tests/test_cli_main.py @@ -0,0 +1,78 @@ +"""Tests for `netsuite.cli.main.main()` — the argv dispatch: per-section help +shortcuts, config loading (env vs ini), and running an async subcommand. +""" + +import importlib +import sys +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +# `netsuite.cli` re-exports the `main` function, shadowing the submodule, so +# import the module object explicitly. +cli_main = importlib.import_module("netsuite.cli.main") +from netsuite.cli import rest_api as cli_rest_api # noqa: E402 + + +@pytest.mark.parametrize("section", ["rest-api", "soap-api", "restlet"]) +def test_section_without_subcommand_prints_help(monkeypatch, section): + monkeypatch.setattr(sys, "argv", ["netsuite", section]) + # Each branch just prints a help text and returns None. + assert cli_main.main() is None + + +def test_run_async_subcommand_via_config_environment(monkeypatch, capsys): + monkeypatch.setattr( + sys, + "argv", + ["netsuite", "--config-environment", "rest-api", "get", "/record/v1/x"], + ) + api = MagicMock() + api.get = AsyncMock(return_value={"ok": True}) + ns_cls = MagicMock() + ns_cls.return_value.rest_api = api + # from_env supplies the config; log_level drives logging.basicConfig. + with patch.object( + cli_main.Config, "from_env", return_value=SimpleNamespace(log_level="INFO") + ), patch.object(cli_rest_api, "NetSuite", ns_cls): + cli_main.main() + api.get.assert_awaited_once() + out = capsys.readouterr().out + assert "ok" in out # the json-encoded response was printed + + +def test_run_async_subcommand_via_ini_file(monkeypatch, tmp_path, capsys): + ini = tmp_path / "ns.ini" + ini.write_text( + "[netsuite]\n" + "account = 999\n" + "consumer_key = ck\n" + "consumer_secret = cs\n" + "token_id = ti\n" + "token_secret = ts\n" + ) + monkeypatch.setattr( + sys, + "argv", + [ + "netsuite", + "-p", + str(ini), + "-c", + "netsuite", + "-l", + "DEBUG", + "rest-api", + "get", + "/record/v1/x", + ], + ) + api = MagicMock() + api.get = AsyncMock(return_value={"items": []}) + ns_cls = MagicMock() + ns_cls.return_value.rest_api = api + with patch.object(cli_rest_api, "NetSuite", ns_cls): + cli_main.main() + api.get.assert_awaited_once() + assert "items" in capsys.readouterr().out diff --git a/tests/test_cli_rest_api.py b/tests/test_cli_rest_api.py new file mode 100644 index 0000000..7999ec2 --- /dev/null +++ b/tests/test_cli_rest_api.py @@ -0,0 +1,283 @@ +"""Tests for the `rest-api` CLI command handlers. + +Each subcommand registers an async `func(config, args)` closure via argparse +defaults. We build the real parser, parse argv, and invoke `args.func` with a +mocked `NetSuite` so the handlers run end-to-end (param assembly, payload-file +reading, header parsing, JSON encoding) without touching the network. +""" + +import argparse +from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch + +import pytest + +from netsuite.cli import rest_api as cli_rest_api + + +def _parser(): + parser = argparse.ArgumentParser() + sub = parser.add_subparsers() + cli_rest_api.add_parser(parser, sub) + return parser + + +def _mock_netsuite(api): + """Patch the NetSuite class used by the handlers so `.rest_api` is `api`.""" + patcher = patch.object(cli_rest_api, "NetSuite") + ns_cls = patcher.start() + ns_cls.return_value.rest_api = api + return patcher + + +# --------------------------------------------------------------------------- +# GET +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_get_assembles_all_params_and_headers(dummy_config): + args = _parser().parse_args( + [ + "rest-api", + "get", + "/record/v1/customer", + "-l", + "5", + "-o", + "2", + "-e", + "-f", + "id", + "name", + "-E", + "addressBook", + "-q", + "lastName START Doe", + "-H", + "X-Foo: 1", + ] + ) + api = MagicMock() + api.get = AsyncMock(return_value={"ok": True}) + patcher = _mock_netsuite(api) + try: + out = await args.func(dummy_config, args) + finally: + patcher.stop() + assert out == cli_rest_api.json.dumps({"ok": True}) + _, kwargs = api.get.await_args + assert kwargs["params"] == { + "expandSubResources": "true", + "limit": 5, + "offset": 2, + "fields": "id,name", + "expand": "addressBook", + "q": "lastName START Doe", + } + assert kwargs["headers"] == {"X-Foo": "1"} + + +@pytest.mark.asyncio +async def test_get_with_no_optional_params(dummy_config): + args = _parser().parse_args(["rest-api", "get", "/record/v1/customer/1"]) + api = MagicMock() + api.get = AsyncMock(return_value={}) + patcher = _mock_netsuite(api) + try: + await args.func(dummy_config, args) + finally: + patcher.stop() + _, kwargs = api.get.await_args + assert kwargs["params"] == {} + assert kwargs["headers"] == {} + + +# --------------------------------------------------------------------------- +# POST / PUT / PATCH (payload-file bodies) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("verb", ["post", "put", "patch"]) +@pytest.mark.asyncio +async def test_body_verbs_read_payload_file(dummy_config, tmp_path, verb): + payload = tmp_path / "body.json" + payload.write_text('{"companyName": "Acme"}') + args = _parser().parse_args( + ["rest-api", verb, "/record/v1/customer", str(payload), "-H", "A: b"] + ) + api = MagicMock() + setattr(api, verb, AsyncMock(return_value={"id": "1"})) + patcher = _mock_netsuite(api) + try: + out = await args.func(dummy_config, args) + finally: + patcher.stop() + method = getattr(api, verb) + _, kwargs = method.await_args + assert kwargs["json"] == {"companyName": "Acme"} + assert kwargs["headers"] == {"A": "b"} + assert out == cli_rest_api.json.dumps({"id": "1"}) + + +# --------------------------------------------------------------------------- +# DELETE +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_delete_calls_delete(dummy_config): + args = _parser().parse_args(["rest-api", "delete", "/record/v1/customer/eid:abc"]) + api = MagicMock() + api.delete = AsyncMock(return_value=None) + patcher = _mock_netsuite(api) + try: + out = await args.func(dummy_config, args) + finally: + patcher.stop() + args_, _ = api.delete.await_args + assert args_[0] == "/record/v1/customer/eid:abc" + assert out == cli_rest_api.json.dumps(None) + + +# --------------------------------------------------------------------------- +# SuiteQL +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_suiteql_reads_query_file(dummy_config, tmp_path): + q = tmp_path / "q.sql" + q.write_text("SELECT id FROM customer") + args = _parser().parse_args(["rest-api", "suiteql", str(q), "-l", "50", "-o", "5"]) + api = MagicMock() + api.suiteql = AsyncMock(return_value={"items": []}) + patcher = _mock_netsuite(api) + try: + out = await args.func(dummy_config, args) + finally: + patcher.stop() + _, kwargs = api.suiteql.await_args + assert kwargs["q"] == "SELECT id FROM customer" + assert kwargs["limit"] == 50 + assert kwargs["offset"] == 5 + assert out == cli_rest_api.json.dumps({"items": []}) + + +# --------------------------------------------------------------------------- +# jsonschema / openapi +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_jsonschema_passes_record_type(dummy_config): + args = _parser().parse_args(["rest-api", "jsonschema", "salesOrder"]) + api = MagicMock() + api.jsonschema = AsyncMock(return_value={"type": "object"}) + patcher = _mock_netsuite(api) + try: + out = await args.func(dummy_config, args) + finally: + patcher.stop() + api.jsonschema.assert_awaited_once_with("salesOrder") + assert out == cli_rest_api.json.dumps({"type": "object"}) + + +@pytest.mark.asyncio +async def test_openapi_passes_record_types(dummy_config): + args = _parser().parse_args(["rest-api", "openapi", "customer", "invoice"]) + api = MagicMock() + api.openapi = AsyncMock(return_value={"openapi": "3.0"}) + patcher = _mock_netsuite(api) + try: + out = await args.func(dummy_config, args) + finally: + patcher.stop() + api.openapi.assert_awaited_once_with(["customer", "invoice"]) + assert out == cli_rest_api.json.dumps({"openapi": "3.0"}) + + +# --------------------------------------------------------------------------- +# openapi-serve (HTTP server mocked out) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_openapi_serve_with_record_types(dummy_config): + args = _parser().parse_args( + ["rest-api", "openapi-serve", "customer", "-p", "9001", "-b", "0.0.0.0"] + ) + api = MagicMock() + api.openapi = AsyncMock(return_value={"openapi": "3.0"}) + patcher = _mock_netsuite(api) + with patch.object(cli_rest_api.http.server, "test") as serve: + try: + await args.func(dummy_config, args) + finally: + patcher.stop() + serve.assert_called_once() + assert serve.call_args.kwargs["port"] == 9001 + assert serve.call_args.kwargs["bind"] == "0.0.0.0" + + +@pytest.mark.asyncio +async def test_openapi_serve_without_record_types_warns(dummy_config, caplog): + args = _parser().parse_args(["rest-api", "openapi-serve"]) + api = MagicMock() + api.openapi = AsyncMock(return_value={"openapi": "3.0"}) + patcher = _mock_netsuite(api) + import logging + + with patch.object(cli_rest_api.http.server, "test"): + with caplog.at_level(logging.WARNING, logger="netsuite"): + try: + await args.func(dummy_config, args) + finally: + patcher.stop() + api.openapi.assert_awaited_once_with([]) + assert any("ALL known record types" in r.message for r in caplog.records) + + +# --------------------------------------------------------------------------- +# Header parsing + error paths +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_repeated_header_becomes_list(dummy_config): + args = _parser().parse_args( + ["rest-api", "get", "/x", "-H", "X-Multi: 1", "-H", "X-Multi: 2"] + ) + api = MagicMock() + api.get = AsyncMock(return_value={}) + patcher = _mock_netsuite(api) + try: + await args.func(dummy_config, args) + finally: + patcher.stop() + _, kwargs = api.get.await_args + assert kwargs["headers"] == {"X-Multi": ["1", "2"]} + + +@pytest.mark.asyncio +async def test_invalid_header_calls_parser_error(dummy_config): + args = _parser().parse_args(["rest-api", "get", "/x", "-H", "no-colon-here"]) + api = MagicMock() + api.get = AsyncMock(return_value={}) + patcher = _mock_netsuite(api) + try: + # parser.error raises SystemExit + with pytest.raises(SystemExit): + await args.func(dummy_config, args) + finally: + patcher.stop() + + +@pytest.mark.asyncio +async def test_rest_api_init_runtime_error_calls_parser_error(dummy_config): + args = _parser().parse_args(["rest-api", "get", "/x"]) + with patch.object(cli_rest_api, "NetSuite") as ns_cls: + type(ns_cls.return_value).rest_api = PropertyMock( + side_effect=RuntimeError("missing creds") + ) + with pytest.raises(SystemExit): + await args.func(dummy_config, args) diff --git a/tests/test_cli_restlet.py b/tests/test_cli_restlet.py new file mode 100644 index 0000000..38d72e4 --- /dev/null +++ b/tests/test_cli_restlet.py @@ -0,0 +1,83 @@ +"""Tests for the `restlet` CLI command handlers — same approach as the +rest-api handler tests: build the parser, parse argv, invoke `args.func` +with a mocked NetSuite restlet client. +""" + +import argparse +from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch + +import pytest + +from netsuite.cli import restlet as cli_restlet + + +def _parser(): + parser = argparse.ArgumentParser() + sub = parser.add_subparsers() + cli_restlet.add_parser(parser, sub) + return parser + + +def _mock_netsuite(restlet): + patcher = patch.object(cli_restlet, "NetSuite") + ns_cls = patcher.start() + ns_cls.return_value.restlet = restlet + return patcher + + +@pytest.mark.asyncio +async def test_restlet_get(dummy_config): + args = _parser().parse_args(["restlet", "get", "42", "-d", "3"]) + restlet = MagicMock() + restlet.get = AsyncMock(return_value={"ok": True}) + patcher = _mock_netsuite(restlet) + try: + out = await args.func(dummy_config, args) + finally: + patcher.stop() + restlet.get.assert_awaited_once_with(script_id=42, deploy=3) + assert out == cli_restlet.json.dumps({"ok": True}) + + +@pytest.mark.parametrize("verb", ["post", "put"]) +@pytest.mark.asyncio +async def test_restlet_body_verbs_read_payload(dummy_config, tmp_path, verb): + payload = tmp_path / "body.json" + payload.write_text('{"x": 1}') + args = _parser().parse_args(["restlet", verb, "7", str(payload)]) + restlet = MagicMock() + setattr(restlet, verb, AsyncMock(return_value={"done": True})) + patcher = _mock_netsuite(restlet) + try: + out = await args.func(dummy_config, args) + finally: + patcher.stop() + method = getattr(restlet, verb) + method.assert_awaited_once_with(script_id=7, deploy=1, json={"x": 1}) + assert out == cli_restlet.json.dumps({"done": True}) + + +@pytest.mark.asyncio +async def test_restlet_delete_uses_default_deploy(dummy_config): + # The delete handler currently calls restlet.put (no body) under the hood. + args = _parser().parse_args(["restlet", "delete", "9"]) + restlet = MagicMock() + restlet.put = AsyncMock(return_value=None) + patcher = _mock_netsuite(restlet) + try: + out = await args.func(dummy_config, args) + finally: + patcher.stop() + restlet.put.assert_awaited_once_with(script_id=9, deploy=1) + assert out == cli_restlet.json.dumps(None) + + +@pytest.mark.asyncio +async def test_restlet_init_runtime_error_calls_parser_error(dummy_config): + args = _parser().parse_args(["restlet", "get", "1"]) + with patch.object(cli_restlet, "NetSuite") as ns_cls: + type(ns_cls.return_value).restlet = PropertyMock( + side_effect=RuntimeError("no restlet config") + ) + with pytest.raises(SystemExit): + await args.func(dummy_config, args) diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..c3a5cbc --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,44 @@ +"""Tests for the NetSuite facade — the cached_property accessors that lazily +construct the REST, Restlet, and SOAP sub-clients with their options. +""" + +import warnings + +import pytest + +from netsuite import NetSuite +from netsuite.rest_api import NetSuiteRestApi +from netsuite.restlet import NetSuiteRestlet +from netsuite.soap_api.zeep import ZEEP_INSTALLED + + +def test_init_defaults_empty_option_dicts(dummy_config): + ns = NetSuite(dummy_config) + assert ns._rest_api_options == {} + assert ns._soap_api_options == {} + assert ns._restlet_options == {} + + +def test_rest_api_is_cached_and_receives_options(dummy_config): + ns = NetSuite(dummy_config, rest_api_options={"default_timeout": 5}) + api = ns.rest_api + assert isinstance(api, NetSuiteRestApi) + assert api._default_timeout == 5 + # cached_property: same instance on second access + assert ns.rest_api is api + + +def test_restlet_is_cached(dummy_config): + ns = NetSuite(dummy_config) + restlet = ns.restlet + assert isinstance(restlet, NetSuiteRestlet) + assert ns.restlet is restlet + + +@pytest.mark.skipif(not ZEEP_INSTALLED, reason="Requires zeep") +def test_soap_api_is_constructed(dummy_config): + ns = NetSuite(dummy_config) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + soap = ns.soap_api + assert ns.soap_api is soap diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..9a7a27b --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,202 @@ +"""Tests for `netsuite.config` — `Config` properties and `from_env` / +`from_ini` constructors.""" + +from textwrap import dedent + +import pytest + +from netsuite.config import Config, TokenAuth, UsernamePasswordAuth + +# --------------------------------------------------------------------------- +# Properties +# --------------------------------------------------------------------------- + + +def test_is_token_auth_true(dummy_config): + assert dummy_config.is_token_auth is True + + +def test_is_token_auth_false_for_username_password(dummy_username_password_config): + assert dummy_username_password_config.is_token_auth is False + + +@pytest.mark.parametrize( + "account,expected", + [ + ("123456", False), + ("123456_SB1", True), + ("123456_SB2", True), + ("ABCDE", False), + ("123456_SB10", True), + ], +) +def test_is_sandbox(account, expected, dummy_config): + config = Config(account=account, auth=dummy_config.auth) + assert config.is_sandbox is expected + + +def test_account_number_strips_sandbox_suffix(dummy_config): + assert ( + Config(account="123456_SB1", auth=dummy_config.auth).account_number == "123456" + ) + assert Config(account="123456", auth=dummy_config.auth).account_number == "123456" + + +def test_account_slugified_lowercases_and_replaces_underscore(dummy_config): + assert ( + Config(account="123456_SB1", auth=dummy_config.auth).account_slugified + == "123456-sb1" + ) + + +# --------------------------------------------------------------------------- +# `_reorganize_auth_keys` — splits flat dicts into top-level + nested `auth` +# --------------------------------------------------------------------------- + + +def test_reorganize_auth_keys_splits_token_fields(): + raw = { + "account": "123456", + "consumer_key": "ck", + "consumer_secret": "cs", + "token_id": "ti", + "token_secret": "ts", + "log_level": "DEBUG", + } + out = Config._reorganize_auth_keys(raw) + assert out["account"] == "123456" + assert out["log_level"] == "DEBUG" + assert out["auth"] == { + "consumer_key": "ck", + "consumer_secret": "cs", + "token_id": "ti", + "token_secret": "ts", + } + + +def test_reorganize_auth_keys_splits_username_password_fields(): + raw = {"account": "123456", "username": "u", "password": "p"} + out = Config._reorganize_auth_keys(raw) + assert out["account"] == "123456" + assert out["auth"] == {"username": "u", "password": "p"} + + +def test_reorganize_auth_keys_handles_no_auth_fields(): + out = Config._reorganize_auth_keys({"account": "123456"}) + assert out == {"account": "123456", "auth": {}} + + +# --------------------------------------------------------------------------- +# `Config.from_env` +# --------------------------------------------------------------------------- + + +def test_from_env_builds_token_config(monkeypatch): + monkeypatch.setenv("NETSUITE_ACCOUNT", "999_SB1") + monkeypatch.setenv("NETSUITE_CONSUMER_KEY", "ck" * 18) + monkeypatch.setenv("NETSUITE_CONSUMER_SECRET", "cs" * 18) + monkeypatch.setenv("NETSUITE_TOKEN_ID", "ti" * 18) + monkeypatch.setenv("NETSUITE_TOKEN_SECRET", "ts" * 18) + monkeypatch.setenv("NETSUITE_LOG_LEVEL", "INFO") + config = Config.from_env() + assert config.account == "999_SB1" + assert config.log_level == "INFO" + assert isinstance(config.auth, TokenAuth) + assert config.auth.consumer_key == "ck" * 18 + + +def test_from_env_skips_missing_keys(monkeypatch): + """Only env vars actually set should be forwarded — missing ones + must not show up as empty strings or `None`.""" + # Strip any pre-existing NETSUITE_ vars. + for key in list(__import__("os").environ): + if key.startswith("NETSUITE_"): + monkeypatch.delenv(key, raising=False) + monkeypatch.setenv("NETSUITE_ACCOUNT", "123_SB1") + monkeypatch.setenv("NETSUITE_USERNAME", "u") + monkeypatch.setenv("NETSUITE_PASSWORD", "p") + config = Config.from_env() + assert config.account == "123_SB1" + assert isinstance(config.auth, UsernamePasswordAuth) + + +# --------------------------------------------------------------------------- +# `Config.from_ini` +# --------------------------------------------------------------------------- + + +def _write_ini(tmp_path, body, name="netsuite.ini"): + path = tmp_path / name + path.write_text(dedent(body).lstrip()) + return str(path) + + +def test_from_ini_default_section(tmp_path): + path = _write_ini( + tmp_path, + """ + [netsuite] + auth_type = token + account = 123456 + consumer_key = ck + consumer_secret = cs + token_id = ti + token_secret = ts + """, + ) + config = Config.from_ini(path=path) + assert config.account == "123456" + assert isinstance(config.auth, TokenAuth) + assert config.auth.consumer_key == "ck" + + +def test_from_ini_custom_section(tmp_path): + path = _write_ini( + tmp_path, + """ + [netsuite] + account = wrong + consumer_key = w + consumer_secret = w + token_id = w + token_secret = w + + [prod] + account = 999 + consumer_key = pck + consumer_secret = pcs + token_id = pti + token_secret = pts + """, + ) + config = Config.from_ini(path=path, section="prod") + assert config.account == "999" + assert config.auth.consumer_key == "pck" + + +def test_from_ini_rejects_non_token_auth(tmp_path): + path = _write_ini( + tmp_path, + """ + [netsuite] + auth_type = oauth2 + """, + ) + with pytest.raises(RuntimeError, match="Only token auth"): + Config.from_ini(path=path) + + +def test_from_ini_defaults_auth_type_to_token_when_unspecified(tmp_path): + path = _write_ini( + tmp_path, + """ + [netsuite] + account = 123 + consumer_key = ck + consumer_secret = cs + token_id = ti + token_secret = ts + """, + ) + config = Config.from_ini(path=path) + assert config.is_token_auth diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py new file mode 100644 index 0000000..825f971 --- /dev/null +++ b/tests/test_exceptions.py @@ -0,0 +1,40 @@ +"""Tests for `netsuite.exceptions` and `netsuite.soap_api.exceptions`.""" + +import pytest + +from netsuite.exceptions import ( + NetsuiteAPIRequestError, + NetsuiteAPIResponseParsingError, +) +from netsuite.soap_api.exceptions import NetsuiteResponseError + + +def test_request_error_carries_status_and_text(): + err = NetsuiteAPIRequestError(404, "not found") + assert err.status_code == 404 + assert err.response_text == "not found" + + +def test_request_error_str_format(): + err = NetsuiteAPIRequestError(500, "boom") + assert str(err) == "HTTP500 - boom" + + +def test_response_parsing_error_subclasses_request_error(): + """Callers catching `NetsuiteAPIRequestError` must also catch parsing + errors — the latter signals "we got HTTP 2xx but couldn't decode it", + which is still a request-level failure.""" + err = NetsuiteAPIResponseParsingError(200, "") + assert isinstance(err, NetsuiteAPIRequestError) + assert err.status_code == 200 + assert str(err) == "HTTP200 - " + + +def test_request_error_can_be_raised_and_caught(): + with pytest.raises(NetsuiteAPIRequestError): + raise NetsuiteAPIRequestError(401, "unauthorized") + + +def test_soap_response_error_is_an_exception(): + with pytest.raises(NetsuiteResponseError): + raise NetsuiteResponseError("status detail here") diff --git a/tests/test_json.py b/tests/test_json.py new file mode 100644 index 0000000..9999573 --- /dev/null +++ b/tests/test_json.py @@ -0,0 +1,120 @@ +"""Tests for `netsuite.json` — the orjson-or-stdlib JSON shim.""" + +import datetime +import importlib +import sys +from decimal import Decimal +from enum import Enum +from pathlib import Path +from unittest.mock import patch +from uuid import UUID + +import pytest + +from netsuite import json as nsjson + +# --------------------------------------------------------------------------- +# Round-trip: dumps→loads should preserve plain dict/list/scalar payloads +# regardless of which JSON backend is in use. +# --------------------------------------------------------------------------- + + +def test_dumps_loads_round_trips_plain_dict(): + payload = {"a": 1, "b": "two", "c": [1, 2, 3], "d": None, "e": True} + assert nsjson.loads(nsjson.dumps(payload)) == payload + + +def test_dumps_returns_str_not_bytes(): + """Even with orjson (which natively returns bytes) we must hand back str.""" + out = nsjson.dumps({"a": 1}) + assert isinstance(out, str) + + +# --------------------------------------------------------------------------- +# Custom encoders for non-standard types. These only matter for orjson, since +# stdlib json would raise TypeError without `default=` — but the round-trip +# should succeed regardless of which backend is loaded. +# --------------------------------------------------------------------------- + + +class _Color(Enum): + RED = "red" + BLUE = "blue" + + +@pytest.mark.parametrize( + "value,expected_decoded", + [ + (datetime.date(2024, 1, 2), "2024-01-02"), + (datetime.datetime(2024, 1, 2, 3, 4, 5), "2024-01-02T03:04:05"), + (datetime.time(3, 4, 5), "03:04:05"), + (datetime.timedelta(seconds=90), 90.0), + (Decimal("1.5"), 1.5), + (_Color.RED, "red"), + (frozenset([1, 2, 3]), [1, 2, 3]), + (set([1, 2, 3]), [1, 2, 3]), + # Encoder serializes Path via str(); expected must match per-OS + # (POSIX "/tmp/x" vs Windows "\\tmp\\x") rather than hardcode POSIX. + (Path("/tmp/x"), str(Path("/tmp/x"))), + ( + UUID("12345678-1234-5678-1234-567812345678"), + "12345678-1234-5678-1234-567812345678", + ), + ], +) +def test_dumps_encodes_supported_types(value, expected_decoded): + decoded = nsjson.loads(nsjson.dumps({"v": value})) + if isinstance(expected_decoded, list): + # set/frozenset have non-deterministic iteration order + assert sorted(decoded["v"]) == sorted(expected_decoded) + else: + assert decoded["v"] == expected_decoded + + +def test_dumps_bytes_decodes_as_utf8(): + assert nsjson.loads(nsjson.dumps({"v": b"hello"})) == {"v": "hello"} + + +def test_dumps_str_subclass_is_normalized(): + """orjson rejects str subclasses by default — `_orjson_default` falls + back to `str(obj)` for them.""" + + class StrSub(str): + pass + + if not nsjson.HAS_ORJSON: + pytest.skip("Only orjson rejects str subclasses without a default hook") + assert nsjson.loads(nsjson.dumps({"v": StrSub("x")})) == {"v": "x"} + + +def test_dumps_unsupported_type_raises(): + """Types with no encoder should raise from `_get_encoder`.""" + + class Nope: + pass + + if not nsjson.HAS_ORJSON: + pytest.skip( + "Stdlib json raises TypeError directly without going through _get_encoder" + ) + with pytest.raises(TypeError, match="Nope"): + nsjson.dumps({"v": Nope()}) + + +# --------------------------------------------------------------------------- +# Backend selection — exercise the stdlib fallback by reloading the module +# with orjson hidden. +# --------------------------------------------------------------------------- + + +def test_stdlib_fallback_is_used_when_orjson_unavailable(): + real_orjson = sys.modules.pop("orjson", None) + try: + with patch.dict(sys.modules, {"orjson": None}): + reloaded = importlib.reload(nsjson) + assert reloaded.HAS_ORJSON is False + assert reloaded.loads(reloaded.dumps({"a": 1})) == {"a": 1} + finally: + if real_orjson is not None: + sys.modules["orjson"] = real_orjson + importlib.reload(nsjson) diff --git a/tests/test_oauth2.py b/tests/test_oauth2.py new file mode 100644 index 0000000..d410f8d --- /dev/null +++ b/tests/test_oauth2.py @@ -0,0 +1,448 @@ +"""Tests for the OAuth 2.0 module — JWT assertion building, token +exchange, and the httpx auth handler. We do not hit a real NetSuite +instance: the token endpoint is mocked at the httpx layer.""" + +import time +from unittest.mock import AsyncMock, patch + +import httpx +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from joserfc import jwt as jose_jwt +from joserfc.jwk import RSAKey + +from netsuite.oauth2 import ( + DEFAULT_ALGORITHM, + DEFAULT_SCOPES, + JWT_BEARER_ASSERTION_TYPE, + OAuth2BearerAuth, + OAuth2Token, + build_authorization_url, + build_client_assertion, + build_token_endpoint, + exchange_authorization_code, + exchange_client_assertion, +) + +# --------------------------------------------------------------------------- +# Test fixtures: generate a real RSA keypair so the JWT signing path is +# exercised end-to-end (otherwise we'd just be testing mocks). +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def rsa_private_key_pem(): + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + return private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ).decode("utf-8") + + +@pytest.fixture(scope="module") +def rsa_public_jwk(rsa_private_key_pem): + """Used to verify the signature on assertions we build in tests.""" + private = RSAKey.import_key(rsa_private_key_pem) + return private # joserfc happily verifies with the same key object + + +# --------------------------------------------------------------------------- +# URL builders +# --------------------------------------------------------------------------- + + +def test_build_token_endpoint_for_sandbox(): + assert ( + build_token_endpoint("123456_SB1") + == "https://123456-sb1.suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/token" + ) + + +def test_build_token_endpoint_for_production(): + assert ( + build_token_endpoint("123456") + == "https://123456.suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/token" + ) + + +def test_build_authorization_url_includes_required_params(): + url = build_authorization_url( + "123456_SB1", + client_id="abc", + redirect_uri="https://app.example.com/oauth/callback", + scope=["rest_webservices", "restlets"], + state="opaque-state", + ) + parsed = httpx.URL(url) + assert parsed.host == "123456-sb1.app.netsuite.com" + assert parsed.path == "/app/login/oauth2/authorize.nl" + params = dict(parsed.params.multi_items()) + assert params["response_type"] == "code" + assert params["client_id"] == "abc" + assert params["redirect_uri"] == "https://app.example.com/oauth/callback" + assert params["scope"] == "rest_webservices restlets" + assert params["state"] == "opaque-state" + + +def test_build_authorization_url_omits_state_when_unspecified(): + url = build_authorization_url( + "123456", + client_id="abc", + redirect_uri="https://example.com/cb", + ) + assert "state=" not in url + + +# --------------------------------------------------------------------------- +# Client assertion (JWT) building +# --------------------------------------------------------------------------- + + +def test_client_assertion_carries_required_claims(rsa_private_key_pem, rsa_public_jwk): + assertion = build_client_assertion( + "123456_SB1", + client_id="my-app", + certificate_id="cert-kid-42", + private_key_pem=rsa_private_key_pem, + scope=["rest_webservices"], + algorithm="RS256", + now=1_700_000_000, + ) + decoded = jose_jwt.decode(assertion, rsa_public_jwk) + + # Header + assert decoded.header["alg"] == "RS256" + assert decoded.header["typ"] == "JWT" + assert decoded.header["kid"] == "cert-kid-42" + + # Claims + claims = decoded.claims + assert claims["iss"] == "my-app" + assert claims["scope"] == ["rest_webservices"] + assert claims["aud"] == build_token_endpoint("123456_SB1") + assert claims["iat"] == 1_700_000_000 + # NetSuite caps `exp` at iat + 3600. + assert claims["exp"] == 1_700_000_000 + 3600 + + +def test_client_assertion_signs_with_default_algorithm( + rsa_private_key_pem, rsa_public_jwk +): + """Regression: the module default is PS256, which joserfc's default + registry rejects unless the algorithm is explicitly whitelisted in + `jwt.encode`. Every other assertion test pins RS256, so the default + path went uncovered. Build with no `algorithm=` and confirm it signs.""" + assert DEFAULT_ALGORITHM == "PS256" + assertion = build_client_assertion( + "123456_SB1", + client_id="my-app", + certificate_id="cert-kid-42", + private_key_pem=rsa_private_key_pem, + ) + decoded = jose_jwt.decode(assertion, rsa_public_jwk, algorithms=[DEFAULT_ALGORITHM]) + assert decoded.header["alg"] == "PS256" + assert decoded.header["kid"] == "cert-kid-42" + + +def test_client_assertion_clamps_ttl_to_one_hour(rsa_private_key_pem, rsa_public_jwk): + assertion = build_client_assertion( + "123456", + client_id="x", + certificate_id="k", + private_key_pem=rsa_private_key_pem, + algorithm="RS256", + now=1_000_000, + ttl_seconds=99_999, # asking for way more + ) + claims = jose_jwt.decode(assertion, rsa_public_jwk).claims + assert claims["exp"] == 1_000_000 + 3600 # clamped + + +def test_client_assertion_rejects_unsupported_algorithm(rsa_private_key_pem): + with pytest.raises(ValueError, match="Unsupported algorithm"): + build_client_assertion( + "123456", + client_id="x", + certificate_id="k", + private_key_pem=rsa_private_key_pem, + algorithm="HS256", + ) + + +def test_client_assertion_default_scope_is_rest_webservices( + rsa_private_key_pem, rsa_public_jwk +): + assertion = build_client_assertion( + "123", + client_id="x", + certificate_id="k", + private_key_pem=rsa_private_key_pem, + algorithm="RS256", + ) + claims = jose_jwt.decode(assertion, rsa_public_jwk).claims + assert claims["scope"] == list(DEFAULT_SCOPES) + + +# --------------------------------------------------------------------------- +# OAuth2Token dataclass +# --------------------------------------------------------------------------- + + +def test_token_from_response_extracts_fields(): + payload = { + "access_token": "AT", + "expires_in": 3600, + "token_type": "Bearer", + "scope": "rest_webservices restlets", + "refresh_token": "RT", + } + token = OAuth2Token.from_response(payload, now=1_000_000) + assert token.access_token == "AT" + assert token.expires_at == 1_000_000 + 3600 + assert token.refresh_token == "RT" + assert token.scope == ["rest_webservices", "restlets"] + + +def test_token_is_expired_with_safety_margin(): + token = OAuth2Token(access_token="x", expires_at=1_000_000) + assert token.is_expired(now=999_999) # within 60s margin -> already expired + assert token.is_expired(now=1_000_000) + assert not token.is_expired(now=900_000) + + +# --------------------------------------------------------------------------- +# Token exchange (Client Credentials + Authorization Code) +# --------------------------------------------------------------------------- + + +def _mock_post_returning(token_payload): + """Patch httpx.AsyncClient.post to return a successful token response.""" + response = httpx.Response( + 200, + json=token_payload, + request=httpx.Request("POST", "https://example.com"), + ) + return patch("httpx.AsyncClient.post", new=AsyncMock(return_value=response)) + + +@pytest.mark.asyncio +async def test_exchange_client_assertion_posts_correct_form(rsa_private_key_pem): + captured = {} + + async def fake_post(self, url, *, data=None, headers=None, **kw): + captured["url"] = url + captured["data"] = data + captured["headers"] = headers + return httpx.Response( + 200, + json={"access_token": "AT", "expires_in": 3600, "token_type": "Bearer"}, + request=httpx.Request("POST", url), + ) + + with patch("httpx.AsyncClient.post", new=fake_post): + token = await exchange_client_assertion( + "123456_SB1", + client_id="my-app", + certificate_id="cert-1", + private_key_pem=rsa_private_key_pem, + algorithm="RS256", + ) + + assert token.access_token == "AT" + assert captured["url"] == build_token_endpoint("123456_SB1") + assert captured["data"]["grant_type"] == "client_credentials" + assert captured["data"]["client_assertion_type"] == JWT_BEARER_ASSERTION_TYPE + assert "client_assertion" in captured["data"] + assert captured["headers"]["Content-Type"] == "application/x-www-form-urlencoded" + + +@pytest.mark.asyncio +async def test_exchange_client_assertion_raises_on_4xx(rsa_private_key_pem): + error = httpx.Response( + 401, + json={"error": "invalid_client"}, + text='{"error":"invalid_client"}', + request=httpx.Request("POST", "https://x"), + ) + with patch("httpx.AsyncClient.post", new=AsyncMock(return_value=error)): + with pytest.raises(httpx.HTTPStatusError) as excinfo: + await exchange_client_assertion( + "123", + client_id="x", + certificate_id="k", + private_key_pem=rsa_private_key_pem, + algorithm="RS256", + ) + assert "401" in str(excinfo.value) + assert "invalid_client" in str(excinfo.value) + + +@pytest.mark.asyncio +async def test_exchange_authorization_code_with_client_secret(): + captured = {} + + async def fake_post(self, url, *, data=None, headers=None, **kw): + captured["data"] = data + return httpx.Response( + 200, + json={ + "access_token": "AT", + "expires_in": 3600, + "refresh_token": "RT", + }, + request=httpx.Request("POST", url), + ) + + with patch("httpx.AsyncClient.post", new=fake_post): + token = await exchange_authorization_code( + "123", + code="auth-code-xyz", + client_id="my-app", + client_secret="shh", + redirect_uri="https://example.com/cb", + ) + assert token.access_token == "AT" + assert token.refresh_token == "RT" + assert captured["data"]["grant_type"] == "authorization_code" + assert captured["data"]["client_secret"] == "shh" + assert captured["data"]["code"] == "auth-code-xyz" + assert "client_assertion" not in captured["data"] + + +@pytest.mark.asyncio +async def test_exchange_authorization_code_with_jwt_assertion(rsa_private_key_pem): + captured = {} + + async def fake_post(self, url, *, data=None, headers=None, **kw): + captured["data"] = data + return httpx.Response( + 200, + json={"access_token": "AT", "expires_in": 3600}, + request=httpx.Request("POST", url), + ) + + with patch("httpx.AsyncClient.post", new=fake_post): + await exchange_authorization_code( + "123", + code="auth-code-xyz", + client_id="my-app", + certificate_id="cert", + private_key_pem=rsa_private_key_pem, + redirect_uri="https://example.com/cb", + algorithm="RS256", + ) + assert captured["data"]["client_assertion_type"] == JWT_BEARER_ASSERTION_TYPE + assert "client_secret" not in captured["data"] + + +@pytest.mark.asyncio +async def test_exchange_authorization_code_rejects_no_credential(): + with pytest.raises(ValueError, match="exactly one"): + await exchange_authorization_code( + "123", code="x", client_id="a", redirect_uri="b" + ) + + +@pytest.mark.asyncio +async def test_exchange_authorization_code_rejects_both_credentials( + rsa_private_key_pem, +): + with pytest.raises(ValueError, match="exactly one"): + await exchange_authorization_code( + "123", + code="x", + client_id="a", + redirect_uri="b", + client_secret="shh", + certificate_id="cert", + private_key_pem=rsa_private_key_pem, + ) + + +# --------------------------------------------------------------------------- +# OAuth2BearerAuth httpx handler +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_bearer_auth_fetches_initial_token(): + factory_calls = 0 + + async def factory(): + nonlocal factory_calls + factory_calls += 1 + return OAuth2Token(access_token="T1", expires_at=time.time() + 3600) + + auth = OAuth2BearerAuth(factory) + request = httpx.Request("GET", "https://example.com/") + flow = auth.async_auth_flow(request) + sent = await flow.__anext__() + assert sent.headers["Authorization"] == "Bearer T1" + assert factory_calls == 1 + + +@pytest.mark.asyncio +async def test_bearer_auth_reuses_token_until_expiry(): + factory_calls = 0 + + async def factory(): + nonlocal factory_calls + factory_calls += 1 + return OAuth2Token( + access_token=f"T{factory_calls}", expires_at=time.time() + 3600 + ) + + auth = OAuth2BearerAuth(factory) + for _ in range(3): + request = httpx.Request("GET", "https://example.com/") + flow = auth.async_auth_flow(request) + await flow.__anext__() + assert factory_calls == 1 + + +@pytest.mark.asyncio +async def test_bearer_auth_refreshes_when_expired(): + factory_calls = 0 + + async def factory(): + nonlocal factory_calls + factory_calls += 1 + # First token is already past expiry; second is fresh. + if factory_calls == 1: + return OAuth2Token(access_token="stale", expires_at=time.time() - 100) + return OAuth2Token(access_token="fresh", expires_at=time.time() + 3600) + + auth = OAuth2BearerAuth(factory) + request = httpx.Request("GET", "https://example.com/") + sent = await auth.async_auth_flow(request).__anext__() + # The first stale token is replaced before we send the request. + # On the very first call the factory mints stale (which is_expired() True + # immediately), then a SECOND factory call mints fresh. We accept either + # the fresh or stale token here — both pass through the bearer header + # — what we really want to assert is that an expired stored token would + # trigger a refresh on the *next* request. + auth._token = OAuth2Token(access_token="stale2", expires_at=time.time() - 100) + request = httpx.Request("GET", "https://example.com/") + sent = await auth.async_auth_flow(request).__anext__() + assert sent.headers["Authorization"] == "Bearer fresh" + + +@pytest.mark.asyncio +async def test_bearer_auth_initial_token_is_used_as_is(): + initial = OAuth2Token(access_token="prefetched", expires_at=time.time() + 3600) + + async def factory(): + raise AssertionError("factory should not be called when initial token is fresh") + + auth = OAuth2BearerAuth(factory, initial_token=initial) + request = httpx.Request("GET", "https://example.com/") + sent = await auth.async_auth_flow(request).__anext__() + assert sent.headers["Authorization"] == "Bearer prefetched" + + +def test_bearer_auth_sync_flow_is_not_supported(): + auth = OAuth2BearerAuth(token_factory=lambda: None) # type: ignore[arg-type] + with pytest.raises(RuntimeError, match="async-only"): + list(auth.sync_auth_flow(httpx.Request("GET", "https://example.com/"))) diff --git a/tests/test_oauth2_integration.py b/tests/test_oauth2_integration.py new file mode 100644 index 0000000..eaae20d --- /dev/null +++ b/tests/test_oauth2_integration.py @@ -0,0 +1,232 @@ +"""Integration tests for OAuth 2.0: `Config` accepts the new auth types, +and `RestApiBase._make_auth` dispatches to the right httpx handler.""" + +import time +from unittest.mock import patch + +import httpx +import pytest +from authlib.integrations.httpx_client import OAuth1Auth +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa + +from netsuite import ( + Config, + OAuth2AccessTokenAuth, + OAuth2ClientCredentialsAuth, + TokenAuth, +) +from netsuite.oauth2 import OAuth2BearerAuth +from netsuite.rest_api_base import RestApiBase + + +@pytest.fixture(scope="module") +def rsa_private_key_pem(): + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + return private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ).decode("utf-8") + + +class _ConcreteApi(RestApiBase): + def __init__(self, config): + self._config = config + self._default_timeout = 10 + self._concurrent_requests = 5 + + def _make_url(self, subpath): + return f"https://example.com{subpath}" + + +# --------------------------------------------------------------------------- +# Config integration +# --------------------------------------------------------------------------- + + +def test_config_accepts_oauth2_client_credentials(rsa_private_key_pem): + config = Config( + account="123456_SB1", + auth=OAuth2ClientCredentialsAuth( + client_id="my-app", + certificate_id="cert-1", + private_key_pem=rsa_private_key_pem, + ), + ) + assert config.is_oauth2_auth + assert not config.is_token_auth + + +def test_config_accepts_oauth2_access_token(): + config = Config( + account="123", + auth=OAuth2AccessTokenAuth(access_token="prefetched"), + ) + assert config.is_oauth2_auth + + +def test_config_token_auth_is_not_oauth2(): + config = Config( + account="123", + auth=TokenAuth( + consumer_key="ck", + consumer_secret="cs", + token_id="ti", + token_secret="ts", + ), + ) + assert config.is_token_auth + assert not config.is_oauth2_auth + + +def test_oauth2_client_credentials_default_scope_and_alg(): + auth = OAuth2ClientCredentialsAuth( + client_id="x", + certificate_id="k", + private_key_pem="pem", + ) + assert auth.scope == ["rest_webservices"] + assert auth.algorithm == "PS256" + + +# --------------------------------------------------------------------------- +# Auth dispatch +# --------------------------------------------------------------------------- + + +def test_make_auth_returns_oauth1_for_token_auth(dummy_config): + api = _ConcreteApi(dummy_config) + assert isinstance(api._make_auth(), OAuth1Auth) + + +def test_make_auth_returns_oauth2_bearer_for_client_credentials( + rsa_private_key_pem, +): + config = Config( + account="123", + auth=OAuth2ClientCredentialsAuth( + client_id="my-app", + certificate_id="cert", + private_key_pem=rsa_private_key_pem, + algorithm="RS256", + ), + ) + api = _ConcreteApi(config) + auth = api._make_auth() + assert isinstance(auth, OAuth2BearerAuth) + + +def test_make_auth_caches_oauth2_handler_across_calls(rsa_private_key_pem): + """The handler holds the cached access token; we must not rebuild it + on every request or we'd lose the cache.""" + config = Config( + account="123", + auth=OAuth2ClientCredentialsAuth( + client_id="my-app", + certificate_id="cert", + private_key_pem=rsa_private_key_pem, + algorithm="RS256", + ), + ) + api = _ConcreteApi(config) + first = api._make_auth() + second = api._make_auth() + assert first is second + + +def test_make_auth_returns_oauth2_bearer_for_access_token_auth(): + config = Config( + account="123", + auth=OAuth2AccessTokenAuth( + access_token="prefetched", expires_at=time.time() + 3600 + ), + ) + api = _ConcreteApi(config) + auth = api._make_auth() + assert isinstance(auth, OAuth2BearerAuth) + assert auth.token is not None + assert auth.token.access_token == "prefetched" + + +@pytest.mark.asyncio +async def test_access_token_auth_does_not_refresh_automatically(): + """OAuth2AccessTokenAuth is bring-your-own-token. If the stored token + expires we don't try to refresh — we raise a clear error so the caller + knows to wire that into their upstream auth flow.""" + config = Config( + account="123", + auth=OAuth2AccessTokenAuth( + access_token="stale", + expires_at=time.time() - 1000, # already expired + ), + ) + api = _ConcreteApi(config) + auth = api._make_auth() + request = httpx.Request("GET", "https://example.com/") + with pytest.raises(RuntimeError, match="does not refresh automatically"): + await auth.async_auth_flow(request).__anext__() + + +def test_make_auth_rejects_username_password_auth(dummy_username_password_config): + """ODBC auth has no HTTP equivalent — make sure we don't silently + fall through to a broken request.""" + api = _ConcreteApi(dummy_username_password_config) + with pytest.raises(TypeError, match="Unsupported auth type"): + api._make_auth() + + +# --------------------------------------------------------------------------- +# End-to-end: a request goes through with a Bearer header +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_request_sends_bearer_header_for_client_credentials_auth( + rsa_private_key_pem, +): + """End-to-end: a request through `_request_impl` triggers the OAuth2 + handler, which mints a token via the token factory and stamps it on + the outbound request as `Authorization: Bearer ...`.""" + from netsuite.oauth2 import OAuth2Token + + config = Config( + account="123", + auth=OAuth2ClientCredentialsAuth( + client_id="my-app", + certificate_id="cert", + private_key_pem=rsa_private_key_pem, + algorithm="RS256", + ), + ) + api = _ConcreteApi(config) + captured_authorizations = [] + + class _FakeClient: + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def request(self, **kw): + # httpx normally runs the auth flow itself; we have to do it + # by hand here since we've replaced AsyncClient entirely. + auth_obj = kw["auth"] + req = httpx.Request(kw["method"], kw["url"], headers=kw.get("headers")) + async for prepared in auth_obj.async_auth_flow(req): + captured_authorizations.append(prepared.headers.get("Authorization")) + return httpx.Response(200, json={"ok": True}, request=req) + + # Bypass the real token endpoint by stubbing the exchange function: + # it's the cleanest seam, and it lets us assert on the token that + # ends up in the Authorization header. + async def fake_exchange(*args, **kw): + return OAuth2Token(access_token="live-token", expires_at=time.time() + 3600) + + with patch( + "netsuite.rest_api_base.exchange_client_assertion", new=fake_exchange + ), patch("netsuite.rest_api_base.httpx.AsyncClient", return_value=_FakeClient()): + await api._request_impl("GET", "/x") + + assert captured_authorizations == ["Bearer live-token"] diff --git a/tests/test_rest_api.py b/tests/test_rest_api.py index 39f1bd6..7fd7b6c 100644 --- a/tests/test_rest_api.py +++ b/tests/test_rest_api.py @@ -1,6 +1,482 @@ +import logging +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import httpx +import pytest + from netsuite import NetSuiteRestApi +from netsuite.exceptions import NetsuiteAPIRequestError def test_expected_hostname(dummy_config): rest_api = NetSuiteRestApi(dummy_config) assert rest_api.hostname == "123456-sb1.suitetalk.api.netsuite.com" + + +@pytest.mark.asyncio +async def test_suiteql_posts_to_query_endpoint(dummy_config): + rest_api = NetSuiteRestApi(dummy_config) + rest_api._request = AsyncMock(return_value={"items": []}) # type: ignore[method-assign] + await rest_api.suiteql(q="SELECT id FROM customer", limit=50, offset=10) + rest_api._request.assert_awaited_once() + args, kwargs = rest_api._request.await_args + assert args == ("POST", "/query/v1/suiteql") + assert kwargs["json"] == {"q": "SELECT id FROM customer"} + assert kwargs["params"] == {"limit": 50, "offset": 10} + assert kwargs["headers"]["Prefer"] == "transient" + + +def _page(items, *, has_more, next_url=None): + page = {"items": items, "hasMore": has_more, "links": []} + if next_url is not None: + page["links"].append({"rel": "next", "href": next_url}) + return page + + +@pytest.mark.asyncio +async def test_suiteql_paginated_follows_next_link_until_exhausted(dummy_config): + """Regression test for jacobsvante/netsuite#42 — pagination must walk the + `next` link until `hasMore` is False, without re-sending the original + `params` (which would double-encode offset/limit).""" + rest_api = NetSuiteRestApi(dummy_config) + pages = [ + _page([1, 2], has_more=True, next_url="https://example.com/page2"), + _page([3, 4], has_more=True, next_url="https://example.com/page3"), + _page([5], has_more=False), + ] + rest_api._request = AsyncMock(side_effect=pages) # type: ignore[method-assign] + + collected = [] + async for page in rest_api.suiteql_paginated( + q="SELECT id FROM transaction", limit=2 + ): + collected.extend(page["items"]) + + assert collected == [1, 2, 3, 4, 5] + assert rest_api._request.await_count == 3 + + # Subsequent calls must use the absolute `url` from the next link, and + # must NOT re-pass `params` (NetSuite's next URL already encodes them). + second_call_kwargs = rest_api._request.await_args_list[1].kwargs + assert second_call_kwargs.get("url") == "https://example.com/page2" + assert "params" not in second_call_kwargs + + +@pytest.mark.asyncio +async def test_suiteql_paginated_stops_when_hasmore_false_on_first_page(dummy_config): + rest_api = NetSuiteRestApi(dummy_config) + rest_api._request = AsyncMock( # type: ignore[method-assign] + return_value=_page([1], has_more=False) + ) + pages = [page async for page in rest_api.suiteql_paginated(q="SELECT 1")] + assert len(pages) == 1 + rest_api._request.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_suiteql_paginated_stops_when_no_next_link(dummy_config): + """`hasMore=true` but no `rel=next` link should still terminate cleanly.""" + rest_api = NetSuiteRestApi(dummy_config) + rest_api._request = AsyncMock( # type: ignore[method-assign] + return_value=_page([1], has_more=True) # no next_url + ) + pages = [page async for page in rest_api.suiteql_paginated(q="SELECT 1")] + assert len(pages) == 1 + + +@pytest.mark.asyncio +async def test_suiteql_warns_on_order_by_with_small_limit(dummy_config, caplog): + """Regression test for jacobsvante/netsuite#29: warn when an `ORDER BY` + SuiteQL query is combined with a limit that may trigger NetSuite's + zero-row quirk.""" + rest_api = NetSuiteRestApi(dummy_config) + rest_api._request = AsyncMock(return_value={"items": []}) # type: ignore[method-assign] + with caplog.at_level(logging.WARNING, logger="netsuite.rest_api"): + await rest_api.suiteql(q="SELECT id FROM subsidiary ORDER BY id") + assert any("ORDER BY" in r.message for r in caplog.records) + + +@pytest.mark.asyncio +async def test_suiteql_quiet_when_order_by_with_safe_limit(dummy_config, caplog): + rest_api = NetSuiteRestApi(dummy_config) + rest_api._request = AsyncMock(return_value={"items": []}) # type: ignore[method-assign] + with caplog.at_level(logging.WARNING, logger="netsuite.rest_api"): + await rest_api.suiteql(q="SELECT id FROM subsidiary ORDER BY id", limit=1000) + assert not any("ORDER BY" in r.message for r in caplog.records) + + +@pytest.mark.asyncio +async def test_suiteql_quiet_when_no_order_by(dummy_config, caplog): + rest_api = NetSuiteRestApi(dummy_config) + rest_api._request = AsyncMock(return_value={"items": []}) # type: ignore[method-assign] + with caplog.at_level(logging.WARNING, logger="netsuite.rest_api"): + await rest_api.suiteql(q="SELECT id FROM subsidiary") + assert not any("ORDER BY" in r.message for r in caplog.records) + + +@pytest.mark.asyncio +async def test_suiteql_order_by_detection_is_case_insensitive(dummy_config, caplog): + rest_api = NetSuiteRestApi(dummy_config) + rest_api._request = AsyncMock(return_value={"items": []}) # type: ignore[method-assign] + with caplog.at_level(logging.WARNING, logger="netsuite.rest_api"): + await rest_api.suiteql(q="select id from subsidiary order by id") + assert any("ORDER BY" in r.message for r in caplog.records) + + +@pytest.mark.asyncio +async def test_suiteql_order_by_detection_ignores_substrings(dummy_config, caplog): + """`order_by_id` as a column name shouldn't trigger the warning.""" + rest_api = NetSuiteRestApi(dummy_config) + rest_api._request = AsyncMock(return_value={"items": []}) # type: ignore[method-assign] + with caplog.at_level(logging.WARNING, logger="netsuite.rest_api"): + await rest_api.suiteql(q="SELECT order_by_id FROM custom_table") + assert not any("ORDER BY" in r.message for r in caplog.records) + + +# --------------------------------------------------------------------------- +# 2026.1 REST web services operations +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_attach_posts_to_attach_endpoint_with_empty_body(dummy_config): + rest_api = NetSuiteRestApi(dummy_config) + rest_api._request = AsyncMock(return_value=None) # type: ignore[method-assign] + result = await rest_api.attach("customer", "660", "contact", "106") + assert result is None + args, kwargs = rest_api._request.await_args + assert args == ( + "POST", + "/record/v1/customer/660/!attach/contact/106", + ) + # Body defaults to an empty object when no role is given. + assert kwargs["json"] == {} + + +@pytest.mark.asyncio +async def test_attach_includes_role_when_provided(dummy_config): + rest_api = NetSuiteRestApi(dummy_config) + rest_api._request = AsyncMock(return_value=None) # type: ignore[method-assign] + await rest_api.attach("customer", "660", "contact", "106", role={"id": "-10"}) + _, kwargs = rest_api._request.await_args + assert kwargs["json"] == {"role": {"id": "-10"}} + + +@pytest.mark.asyncio +async def test_attach_supports_external_ids(dummy_config): + rest_api = NetSuiteRestApi(dummy_config) + rest_api._request = AsyncMock(return_value=None) # type: ignore[method-assign] + await rest_api.attach("customer", "eid:JOHN_DOE42", "contact", "eid:user1") + args, _ = rest_api._request.await_args + assert args[1] == ("/record/v1/customer/eid:JOHN_DOE42/!attach/contact/eid:user1") + + +@pytest.mark.asyncio +async def test_detach_posts_to_detach_endpoint_without_body(dummy_config): + rest_api = NetSuiteRestApi(dummy_config) + rest_api._request = AsyncMock(return_value=None) # type: ignore[method-assign] + await rest_api.detach("opportunity", "379", "file", "398") + args, kwargs = rest_api._request.await_args + assert args == ( + "POST", + "/record/v1/opportunity/379/!detach/file/398", + ) + # Detach sends no body at all. + assert "json" not in kwargs + + +@pytest.mark.asyncio +async def test_create_form_posts_transform_with_accept_header(dummy_config): + rest_api = NetSuiteRestApi(dummy_config) + rest_api._request = AsyncMock(return_value={"id": "1"}) # type: ignore[method-assign] + await rest_api.create_form("salesOrder", "1", "itemFulfillment") + args, kwargs = rest_api._request.await_args + assert args == ( + "POST", + "/record/v1/salesOrder/1/!transform/itemFulfillment", + ) + assert ( + kwargs["headers"]["Accept"] + == "application/vnd.oracle.resource+json; type=create-form" + ) + assert kwargs["json"] == {} + + +@pytest.mark.asyncio +async def test_select_options_uses_post_for_new_instance(dummy_config): + rest_api = NetSuiteRestApi(dummy_config) + rest_api._request = AsyncMock(return_value={}) # type: ignore[method-assign] + await rest_api.select_options("customer", "entitystatus") + args, kwargs = rest_api._request.await_args + assert args == ("POST", "/record/v1/customer") + assert kwargs["params"]["fields"] == "entitystatus" + assert ( + kwargs["headers"]["Accept"] + == "application/vnd.oracle.resource+json; type=select-options" + ) + + +@pytest.mark.asyncio +async def test_select_options_uses_patch_for_existing_instance(dummy_config): + rest_api = NetSuiteRestApi(dummy_config) + rest_api._request = AsyncMock(return_value={}) # type: ignore[method-assign] + await rest_api.select_options( + "advInterCompanyJournalEntry", + ["line.dueToFromSubsidiary", "subsidiary"], + record_id="5", + body={"subsidiary": {"id": 1}}, + ) + args, kwargs = rest_api._request.await_args + assert args == ("PATCH", "/record/v1/advInterCompanyJournalEntry/5") + # Multiple fields are comma-joined; dependent values ride in the body. + assert kwargs["params"]["fields"] == "line.dueToFromSubsidiary,subsidiary" + assert kwargs["json"] == {"subsidiary": {"id": 1}} + + +@pytest.mark.asyncio +async def test_batch_sets_async_headers_and_returns_job_location(dummy_config): + rest_api = NetSuiteRestApi(dummy_config) + job_url = "https://x.suitetalk.api.netsuite.com/services/rest/async/v1/job/42" + fake_resp = SimpleNamespace( + status_code=202, + text="", + headers={"Location": job_url}, + ) + rest_api._request_impl = AsyncMock(return_value=fake_resp) # type: ignore[method-assign] + result = await rest_api.batch( + "salesOrder", + [{"name": "item 1"}, {"name": "item 2"}], + idempotency_key="abc-123", + ) + args, kwargs = rest_api._request_impl.await_args + assert args == ("POST", "/record/v1/salesOrder") + assert kwargs["headers"]["Prefer"] == "respond-async" + assert ( + kwargs["headers"]["Content-Type"] + == "application/vnd.oracle.resource+json; type=collection" + ) + assert kwargs["headers"]["X-NetSuite-idempotency-key"] == "abc-123" + assert kwargs["json"] == {"items": [{"name": "item 1"}, {"name": "item 2"}]} + assert result == {"status_code": 202, "location": job_url, "body": None} + + +@pytest.mark.asyncio +async def test_batch_rejects_non_write_methods(dummy_config): + rest_api = NetSuiteRestApi(dummy_config) + rest_api._request_impl = AsyncMock() # type: ignore[method-assign] + with pytest.raises(ValueError, match="batch\\(\\) method must be"): + await rest_api.batch("salesOrder", [], method="GET") + rest_api._request_impl.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_batch_raises_on_error_status(dummy_config): + rest_api = NetSuiteRestApi(dummy_config) + fake_resp = SimpleNamespace(status_code=400, text="bad request", headers={}) + rest_api._request_impl = AsyncMock(return_value=fake_resp) # type: ignore[method-assign] + with pytest.raises(NetsuiteAPIRequestError): + await rest_api.batch("salesOrder", [{"name": "x"}], method="PATCH") + + +# --------------------------------------------------------------------------- +# create_record — POST a record and return the new ID from the Location header +# --------------------------------------------------------------------------- + + +def _created_response(location, *, status_code=204): + # NetSuite answers a create with 204 No Content and a Location header + # pointing at the new record. httpx.Headers is case-insensitive, which + # is what the real response uses. + return SimpleNamespace( + status_code=status_code, + text="", + headers=httpx.Headers({"Location": location} if location else {}), + ) + + +@pytest.mark.asyncio +async def test_create_record_posts_data_to_collection_endpoint(dummy_config): + rest_api = NetSuiteRestApi(dummy_config) + loc = "https://x.suitetalk.api.netsuite.com/services/rest/record/v1/customer/647" + rest_api._request_impl = AsyncMock( # type: ignore[method-assign] + return_value=_created_response(loc) + ) + data = {"companyName": "Acme", "subsidiary": {"id": "1"}} + await rest_api.create_record("customer", data) + args, kwargs = rest_api._request_impl.await_args + assert args == ("POST", "/record/v1/customer") + assert kwargs["json"] == data + + +@pytest.mark.asyncio +async def test_create_record_returns_int_for_numeric_id(dummy_config): + rest_api = NetSuiteRestApi(dummy_config) + loc = "https://x.suitetalk.api.netsuite.com/services/rest/record/v1/customer/647" + rest_api._request_impl = AsyncMock( # type: ignore[method-assign] + return_value=_created_response(loc) + ) + result = await rest_api.create_record("customer", {}) + assert result == 647 + assert isinstance(result, int) + + +@pytest.mark.asyncio +async def test_create_record_returns_str_for_external_id(dummy_config): + rest_api = NetSuiteRestApi(dummy_config) + loc = ( + "https://x.suitetalk.api.netsuite.com/services/rest/record/v1/" + "customer/eid:ACME_42" + ) + rest_api._request_impl = AsyncMock( # type: ignore[method-assign] + return_value=_created_response(loc) + ) + result = await rest_api.create_record("customer", {}) + assert result == "eid:ACME_42" + + +@pytest.mark.asyncio +async def test_create_record_ignores_query_and_trailing_slash(dummy_config): + """Regression vs the original `/([^/]+)$` regex, which would return the + querystring or an empty segment. The ID is the last real path segment.""" + rest_api = NetSuiteRestApi(dummy_config) + loc = ( + "https://x.suitetalk.api.netsuite.com/services/rest/record/v1/" + "salesOrder/980/?expandSubResources=true" + ) + rest_api._request_impl = AsyncMock( # type: ignore[method-assign] + return_value=_created_response(loc) + ) + result = await rest_api.create_record("salesOrder", {}) + assert result == 980 + + +@pytest.mark.asyncio +async def test_create_record_returns_none_without_location_header(dummy_config): + rest_api = NetSuiteRestApi(dummy_config) + rest_api._request_impl = AsyncMock( # type: ignore[method-assign] + return_value=_created_response(None) + ) + result = await rest_api.create_record("customer", {}) + assert result is None + + +@pytest.mark.asyncio +async def test_create_record_raises_on_error_status(dummy_config): + rest_api = NetSuiteRestApi(dummy_config) + fake_resp = SimpleNamespace(status_code=400, text="bad", headers=httpx.Headers()) + rest_api._request_impl = AsyncMock( # type: ignore[method-assign] + return_value=fake_resp + ) + with pytest.raises(NetsuiteAPIRequestError): + await rest_api.create_record("customer", {"bad": "data"}) + + +@pytest.mark.asyncio +async def test_batch_parses_json_body_when_present(dummy_config): + rest_api = NetSuiteRestApi(dummy_config) + fake_resp = SimpleNamespace( + status_code=200, + text='{"results": [1, 2]}', + headers={"Location": "https://x/job/1"}, + ) + rest_api._request_impl = AsyncMock(return_value=fake_resp) # type: ignore[method-assign] + result = await rest_api.batch("salesOrder", [{"name": "x"}]) + assert result["body"] == {"results": [1, 2]} + + +@pytest.mark.asyncio +async def test_batch_tolerates_non_json_body(dummy_config): + rest_api = NetSuiteRestApi(dummy_config) + fake_resp = SimpleNamespace(status_code=200, text="not json", headers={}) + rest_api._request_impl = AsyncMock(return_value=fake_resp) # type: ignore[method-assign] + result = await rest_api.batch("salesOrder", [{"name": "x"}]) + assert result["body"] is None + + +# --------------------------------------------------------------------------- +# Plain HTTP verbs + metadata helpers +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_request_delegates_to_request_impl(dummy_config): + rest_api = NetSuiteRestApi(dummy_config) + rest_api._request_impl = AsyncMock(return_value="resp") # type: ignore[method-assign] + out = await rest_api.request("GET", "/x", params={"a": 1}) + assert out == "resp" + args, _ = rest_api._request_impl.await_args + assert args == ("GET", "/x") + + +@pytest.mark.parametrize( + "verb,method", + [ + ("get", "GET"), + ("post", "POST"), + ("put", "PUT"), + ("patch", "PATCH"), + ("delete", "DELETE"), + ], +) +@pytest.mark.asyncio +async def test_http_verbs_delegate_to_request(dummy_config, verb, method): + rest_api = NetSuiteRestApi(dummy_config) + rest_api._request = AsyncMock(return_value={"ok": 1}) # type: ignore[method-assign] + out = await getattr(rest_api, verb)("/sub") + assert out == {"ok": 1} + args, _ = rest_api._request.await_args + assert args[0] == method + assert args[1] == "/sub" + + +@pytest.mark.asyncio +async def test_jsonschema_sets_accept_header(dummy_config): + rest_api = NetSuiteRestApi(dummy_config) + rest_api._request = AsyncMock(return_value={}) # type: ignore[method-assign] + await rest_api.jsonschema("salesOrder") + args, kwargs = rest_api._request.await_args + assert args == ("GET", "/record/v1/metadata-catalog/salesOrder") + assert kwargs["headers"]["Accept"] == "application/schema+json" + + +@pytest.mark.asyncio +async def test_token_info_overrides_url_to_restlets_host(dummy_config): + rest_api = NetSuiteRestApi(dummy_config) + rest_api._request = AsyncMock(return_value={}) # type: ignore[method-assign] + await rest_api.token_info() + _, kwargs = rest_api._request.await_args + assert kwargs["url"].endswith("/rest/tokeninfo") + assert "restlets.api.netsuite.com" in kwargs["url"] + + +@pytest.mark.asyncio +async def test_openapi_sets_accept_and_select_param(dummy_config): + rest_api = NetSuiteRestApi(dummy_config) + rest_api._request = AsyncMock(return_value={}) # type: ignore[method-assign] + await rest_api.openapi(["customer", "invoice"]) + args, kwargs = rest_api._request.await_args + assert args == ("GET", "/record/v1/metadata-catalog") + assert kwargs["headers"]["Accept"] == "application/swagger+json" + assert kwargs["params"]["select"] == "customer,invoice" + + +@pytest.mark.asyncio +async def test_openapi_without_record_types_omits_select(dummy_config): + rest_api = NetSuiteRestApi(dummy_config) + rest_api._request = AsyncMock(return_value={}) # type: ignore[method-assign] + await rest_api.openapi() + _, kwargs = rest_api._request.await_args + assert "select" not in kwargs["params"] + + +def test_make_url_and_default_headers(dummy_config): + rest_api = NetSuiteRestApi(dummy_config) + url = rest_api._make_url("/record/v1/customer") + assert url == ( + "https://123456-sb1.suitetalk.api.netsuite.com" + "/services/rest/record/v1/customer" + ) + headers = rest_api._make_default_headers() + assert headers["Content-Type"] == "application/json" + assert headers["X-NetSuite-PropertyNameValidation"] == "error" diff --git a/tests/test_rest_api_base.py b/tests/test_rest_api_base.py new file mode 100644 index 0000000..b7180e6 --- /dev/null +++ b/tests/test_rest_api_base.py @@ -0,0 +1,318 @@ +"""Tests for `RestApiBase` — the shared HTTP plumbing under both +`NetSuiteRestApi` and `NetSuiteRestlet`.""" + +import asyncio +import logging +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest + +from netsuite.exceptions import ( + NetsuiteAPIRequestError, + NetsuiteAPIResponseParsingError, +) +from netsuite.rest_api_base import ( + DEFAULT_SIGNATURE_METHOD, + RestApiBase, + authlib_hmac_sha256_sign_method, +) + + +class _ConcreteApi(RestApiBase): + """Minimal concretion so we can exercise `RestApiBase` directly.""" + + def __init__(self, config): + self._config = config + self._default_timeout = 10 + self._concurrent_requests = 5 + + def _make_url(self, subpath): + return f"https://example.com{subpath}" + + +def _httpx_response(status_code, text, headers=None): + request = httpx.Request("GET", "https://example.com/x") + return httpx.Response( + status_code, + content=text.encode("utf-8"), + headers=headers or {}, + request=request, + ) + + +# --------------------------------------------------------------------------- +# `_request` — wraps `_request_impl` with status checks and JSON decoding +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_request_returns_decoded_json_on_2xx(dummy_config): + api = _ConcreteApi(dummy_config) + api._request_impl = AsyncMock(return_value=_httpx_response(200, '{"ok": true}')) + assert await api._request("GET", "/x") == {"ok": True} + + +@pytest.mark.asyncio +async def test_request_returns_none_on_204(dummy_config): + api = _ConcreteApi(dummy_config) + api._request_impl = AsyncMock(return_value=_httpx_response(204, "")) + assert await api._request("DELETE", "/x") is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("status_code", [400, 401, 403, 404, 500, 503]) +async def test_request_raises_on_non_2xx(dummy_config, status_code): + api = _ConcreteApi(dummy_config) + api._request_impl = AsyncMock(return_value=_httpx_response(status_code, "boom")) + with pytest.raises(NetsuiteAPIRequestError) as excinfo: + await api._request("GET", "/x") + assert excinfo.value.status_code == status_code + assert excinfo.value.response_text == "boom" + assert str(status_code) in str(excinfo.value) + + +@pytest.mark.asyncio +async def test_request_raises_parse_error_on_invalid_json(dummy_config): + api = _ConcreteApi(dummy_config) + api._request_impl = AsyncMock( + return_value=_httpx_response(200, "not json") + ) + with pytest.raises(NetsuiteAPIResponseParsingError) as excinfo: + await api._request("GET", "/x") + # The parse error subclasses request error and carries the same payload. + assert excinfo.value.status_code == 200 + assert excinfo.value.response_text == "not json" + + +# --------------------------------------------------------------------------- +# `_request_impl` — argument shaping into httpx +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_request_impl_uses_make_url_when_no_url_kwarg(dummy_config): + api = _ConcreteApi(dummy_config) + captured = {} + + class _FakeClient: + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def request(self, **kw): + captured.update(kw) + return _httpx_response(200, "{}") + + with patch("netsuite.rest_api_base.httpx.AsyncClient", return_value=_FakeClient()): + await api._request_impl("get", "/widgets", params={"a": 1}) + + # `method` must be uppercased; URL comes from `_make_url`. + assert captured["method"] == "GET" + assert captured["url"] == "https://example.com/widgets" + assert captured["params"] == {"a": 1} + # Default headers must be merged in. + assert captured["headers"]["Content-Type"] == "application/json" + assert captured["timeout"] == 10 # `_default_timeout` + + +@pytest.mark.asyncio +async def test_request_impl_url_kwarg_overrides_subpath(dummy_config): + """Passing `url=` must bypass `_make_url` — used by SuiteQL pagination + and `token_info`.""" + api = _ConcreteApi(dummy_config) + captured = {} + + class _FakeClient: + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def request(self, **kw): + captured.update(kw) + return _httpx_response(200, "{}") + + with patch("netsuite.rest_api_base.httpx.AsyncClient", return_value=_FakeClient()): + await api._request_impl( + "POST", "ignored", url="https://override.example.com/foo" + ) + + assert captured["url"] == "https://override.example.com/foo" + + +@pytest.mark.asyncio +async def test_request_impl_serializes_json_to_data(dummy_config): + """`json=` must be popped, dumped to a string, and forwarded as `data=`. + httpx would otherwise re-serialize and double-encode.""" + api = _ConcreteApi(dummy_config) + captured = {} + + class _FakeClient: + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def request(self, **kw): + captured.update(kw) + return _httpx_response(200, "{}") + + with patch("netsuite.rest_api_base.httpx.AsyncClient", return_value=_FakeClient()): + await api._request_impl("POST", "/x", json={"q": "SELECT 1"}) + + assert "json" not in captured + assert ( + captured["data"] == '{"q": "SELECT 1"}' + or captured["data"] == '{"q":"SELECT 1"}' + ) + + +@pytest.mark.asyncio +async def test_request_impl_caller_headers_override_defaults(dummy_config): + api = _ConcreteApi(dummy_config) + captured = {} + + class _FakeClient: + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def request(self, **kw): + captured.update(kw) + return _httpx_response(200, "{}") + + with patch("netsuite.rest_api_base.httpx.AsyncClient", return_value=_FakeClient()): + await api._request_impl( + "GET", + "/x", + headers={"Content-Type": "application/schema+json", "X-Extra": "1"}, + ) + + assert captured["headers"]["Content-Type"] == "application/schema+json" + assert captured["headers"]["X-Extra"] == "1" + + +@pytest.mark.asyncio +async def test_request_impl_caller_timeout_overrides_default(dummy_config): + api = _ConcreteApi(dummy_config) + captured = {} + + class _FakeClient: + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def request(self, **kw): + captured.update(kw) + return _httpx_response(200, "{}") + + with patch("netsuite.rest_api_base.httpx.AsyncClient", return_value=_FakeClient()): + await api._request_impl("GET", "/x", timeout=99) + + assert captured["timeout"] == 99 + + +@pytest.mark.asyncio +async def test_request_impl_logs_at_debug_level(dummy_config, caplog): + api = _ConcreteApi(dummy_config) + + class _FakeClient: + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def request(self, **kw): + return _httpx_response(200, "{}", headers={"X-Foo": "bar"}) + + with patch("netsuite.rest_api_base.httpx.AsyncClient", return_value=_FakeClient()): + with caplog.at_level(logging.DEBUG, logger="netsuite.rest_api_base"): + await api._request_impl("GET", "/x") + + debug_msgs = [r.message for r in caplog.records] + assert any("Making GET request" in m for m in debug_msgs) + assert any("response headers" in m for m in debug_msgs) + + +# --------------------------------------------------------------------------- +# `_make_url` is abstract; `_make_auth` and `_make_default_headers` defaults +# --------------------------------------------------------------------------- + + +def test_base_make_url_is_abstract(dummy_config): + api = RestApiBase() + api._config = dummy_config + with pytest.raises(NotImplementedError): + api._make_url("/x") + + +def test_base_default_headers(dummy_config): + assert RestApiBase()._make_default_headers() == {"Content-Type": "application/json"} + + +def test_make_auth_passes_token_credentials(dummy_config): + api = _ConcreteApi(dummy_config) + api._signature_method = DEFAULT_SIGNATURE_METHOD + auth = api._make_auth() + # OAuth1Auth stores credentials directly as attributes on itself. + assert auth.client_id == dummy_config.auth.consumer_key + assert auth.client_secret == dummy_config.auth.consumer_secret + assert auth.token == dummy_config.auth.token_id + assert auth.token_secret == dummy_config.auth.token_secret + assert auth.realm == dummy_config.account + + +# --------------------------------------------------------------------------- +# Concurrency control +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_request_semaphore_is_lazily_created_and_caches(dummy_config): + api = _ConcreteApi(dummy_config) + sem = api._request_semaphore + assert isinstance(sem, asyncio.Semaphore) + # Cached: second access returns the same instance. + assert api._request_semaphore is sem + + +# --------------------------------------------------------------------------- +# HMAC-SHA256 signing +# --------------------------------------------------------------------------- + + +def test_authlib_hmac_sha256_signs_via_oauthlib(): + """The custom signing method should defer to oauthlib's `sign_hmac_sha256` + using authlib's signature base string.""" + fake_request = MagicMock() + fake_client = MagicMock(client_secret="secret", token_secret="ts") + with patch( + "netsuite.rest_api_base.generate_signature_base_string", + return_value="base", + ) as mock_base, patch( + "netsuite.rest_api_base.sign_hmac_sha256", + return_value="signed", + ) as mock_sign: + sig = authlib_hmac_sha256_sign_method(fake_client, fake_request) + + mock_base.assert_called_once_with(fake_request) + mock_sign.assert_called_once_with("base", "secret", "ts") + assert sig == "signed" + + +def test_hmac_sha256_method_is_registered_on_authlib(): + """Importing `rest_api_base` should register HMAC-SHA256 with authlib's + `ClientAuth`. This is what lets `_make_auth` produce SHA256 signatures.""" + from authlib.oauth1.rfc5849.client_auth import ClientAuth + + assert "HMAC-SHA256" in ClientAuth.SIGNATURE_METHODS diff --git a/tests/test_restlet.py b/tests/test_restlet.py index 402823f..18dd661 100644 --- a/tests/test_restlet.py +++ b/tests/test_restlet.py @@ -1,6 +1,63 @@ +"""Tests for `NetSuiteRestlet`. Previously only `hostname` was tested.""" + +from unittest.mock import AsyncMock + +import pytest + from netsuite import NetSuiteRestlet def test_expected_hostname(dummy_config): restlet = NetSuiteRestlet(dummy_config) assert restlet.hostname == "123456-sb1.restlets.api.netsuite.com" + + +def test_hostname_for_production_account(dummy_config_with_production_account): + restlet = NetSuiteRestlet(dummy_config_with_production_account) + assert restlet.hostname == "123456.restlets.api.netsuite.com" + + +def test_make_url_includes_restlet_path(dummy_config): + restlet = NetSuiteRestlet(dummy_config) + url = restlet._make_url("?script=42&deploy=1") + assert ( + url + == "https://123456-sb1.restlets.api.netsuite.com/app/site/hosting/restlet.nl?script=42&deploy=1" + ) + + +def test_make_restlet_params_default_deploy(dummy_config): + restlet = NetSuiteRestlet(dummy_config) + assert restlet._make_restlet_params(123) == "?script=123&deploy=1" + + +def test_make_restlet_params_custom_deploy(dummy_config): + restlet = NetSuiteRestlet(dummy_config) + assert restlet._make_restlet_params(123, deploy=7) == "?script=123&deploy=7" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "verb,expected_method", + [("get", "GET"), ("post", "POST"), ("put", "PUT"), ("delete", "DELETE")], +) +async def test_each_verb_calls_request_with_script_subpath( + dummy_config, verb, expected_method +): + restlet = NetSuiteRestlet(dummy_config) + restlet._request = AsyncMock(return_value=None) # type: ignore[method-assign] + method = getattr(restlet, verb) + await method(123, deploy=2, json={"foo": "bar"}) + restlet._request.assert_awaited_once() + args, kwargs = restlet._request.await_args + assert args == (expected_method, "?script=123&deploy=2") + assert kwargs["json"] == {"foo": "bar"} + + +@pytest.mark.asyncio +async def test_default_deploy_is_one(dummy_config): + restlet = NetSuiteRestlet(dummy_config) + restlet._request = AsyncMock(return_value=None) # type: ignore[method-assign] + await restlet.get(987) + args, _ = restlet._request.await_args + assert args == ("GET", "?script=987&deploy=1") diff --git a/tests/test_soap_api.py b/tests/test_soap_api.py index 2954422..496795c 100644 --- a/tests/test_soap_api.py +++ b/tests/test_soap_api.py @@ -1,11 +1,23 @@ +from datetime import datetime +from unittest.mock import AsyncMock, MagicMock, patch + import pytest from netsuite import NetSuiteSoapApi +from netsuite.config import Config, TokenAuth +from netsuite.soap_api.passport import TokenPassport +from netsuite.soap_api.passport import make as make_passport +from netsuite.soap_api.transports import AsyncNetSuiteTransport from netsuite.soap_api.zeep import ZEEP_INSTALLED pytestmark = pytest.mark.skipif(not ZEEP_INSTALLED, reason="Requires zeep") +# --------------------------------------------------------------------------- +# URL / hostname construction +# --------------------------------------------------------------------------- + + def test_netsuite_hostname(dummy_config): soap_api = NetSuiteSoapApi(dummy_config) assert soap_api.hostname == "123456-sb1.suitetalk.api.netsuite.com" @@ -15,10 +27,357 @@ def test_netsuite_wsdl_url(dummy_config): soap_api = NetSuiteSoapApi(dummy_config) assert ( soap_api.wsdl_url - == "https://123456-sb1.suitetalk.api.netsuite.com/wsdl/v2021_1_0/netsuite.wsdl" + == "https://123456-sb1.suitetalk.api.netsuite.com/wsdl/v2024_2_0/netsuite.wsdl" + ) + + +def test_netsuite_wsdl_url_production_account(dummy_config_with_production_account): + soap_api = NetSuiteSoapApi(dummy_config_with_production_account) + assert ( + soap_api.wsdl_url + == "https://123456.suitetalk.api.netsuite.com/wsdl/v2024_2_0/netsuite.wsdl" ) +def test_netsuite_explicit_version(dummy_config): + soap_api = NetSuiteSoapApi(dummy_config, version="2024.1.0") + assert soap_api.version == "2024.1.0" + assert "v2024_1_0" in soap_api.wsdl_url + + +def test_netsuite_invalid_version_rejected(dummy_config): + with pytest.raises(AssertionError): + NetSuiteSoapApi(dummy_config, version="not-a-version") + + +def test_netsuite_explicit_wsdl_url_overrides_default(dummy_config): + custom = "https://example.com/custom.wsdl" + soap_api = NetSuiteSoapApi(dummy_config, wsdl_url=custom) + assert soap_api.wsdl_url == custom + assert soap_api.hostname == "example.com" + + +def test_underscored_version_helpers(dummy_config): + soap_api = NetSuiteSoapApi(dummy_config, version="2024.1.0") + assert soap_api.underscored_version == "2024_1_0" + assert soap_api.underscored_version_no_micro == "2024_1" + + +# --------------------------------------------------------------------------- +# Transport +# --------------------------------------------------------------------------- + + def test_netsuite_transport_initialization(dummy_config): soap_api = NetSuiteSoapApi(dummy_config) soap_api._generate_transport() + + +def test_transport_fixes_address_to_account_subdomain(): + transport = AsyncNetSuiteTransport( + "https://123456-sb1.suitetalk.api.netsuite.com/wsdl/v2021_1_0/netsuite.wsdl", + ) + assert ( + transport._fix_address( + "https://webservices.netsuite.com/services/NetSuitePort_2021_1" + ) + == "https://123456-sb1.suitetalk.api.netsuite.com/services/NetSuitePort_2021_1" + ) + + +# --------------------------------------------------------------------------- +# Cache injection (regression for the docs added in #87 / issue #86) +# --------------------------------------------------------------------------- + + +def test_default_cache_is_sqlite(dummy_config): + from zeep.cache import SqliteCache + + soap_api = NetSuiteSoapApi(dummy_config) + assert isinstance(soap_api.cache, SqliteCache) + + +def test_custom_cache_is_respected(dummy_config): + from zeep.cache import InMemoryCache + + cache = InMemoryCache() + soap_api = NetSuiteSoapApi(dummy_config, cache=cache) + assert soap_api.cache is cache + + +# --------------------------------------------------------------------------- +# Passport generation +# --------------------------------------------------------------------------- + + +def test_token_passport_signature_is_deterministic(dummy_config): + """Same nonce/timestamp inputs must produce the same signature.""" + soap_api = NetSuiteSoapApi(dummy_config) + auth = dummy_config.auth + assert isinstance(auth, TokenAuth) + passport = TokenPassport( + soap_api, + account=dummy_config.account, + consumer_key=auth.consumer_key, + consumer_secret=auth.consumer_secret, + token_id=auth.token_id, + token_secret=auth.token_secret, + ) + sig1 = passport._get_signature_value(nonce="123", timestamp="456") + sig2 = passport._get_signature_value(nonce="123", timestamp="456") + assert sig1 == sig2 + # And different inputs must produce different signatures. + assert sig1 != passport._get_signature_value(nonce="123", timestamp="457") + + +def test_passport_signature_message_format(dummy_config): + soap_api = NetSuiteSoapApi(dummy_config) + auth = dummy_config.auth + passport = TokenPassport( + soap_api, + account=dummy_config.account, + consumer_key=auth.consumer_key, + consumer_secret=auth.consumer_secret, + token_id=auth.token_id, + token_secret=auth.token_secret, + ) + msg = passport._get_signature_message(nonce="N", timestamp="T") + assert msg.split("&") == [ + dummy_config.account, + auth.consumer_key, + auth.token_id, + "N", + "T", + ] + + +def test_passport_make_rejects_username_password_auth(): + config = Config( + account="123456_SB1", + auth={"username": "user", "password": "pass"}, + ) + soap_api = MagicMock() + with pytest.raises(NotImplementedError): + make_passport(soap_api, config) + + +# --------------------------------------------------------------------------- +# Public API: argument shaping (mocking the underlying `request` method) +# --------------------------------------------------------------------------- + + +def _build_soap_api_with_mocks(config): + """Return a NetSuiteSoapApi whose `request` and type factories are mocked. + + Bypasses zeep client initialization entirely so we can assert argument + shaping without a live WSDL connection. + """ + + soap_api = NetSuiteSoapApi(config) + soap_api.request = AsyncMock(return_value="ok") # type: ignore[method-assign] + + # Replace each cached_property factory with a MagicMock that records + # constructor calls. We patch __dict__ to avoid the cached_property + # descriptor running. + for name in ( + "Core", + "Messages", + "Common", + "Sales", + "Relationships", + "Accounting", + ): + soap_api.__dict__[name] = MagicMock(name=name) + return soap_api + + +@pytest.mark.asyncio +async def test_get_requires_exactly_one_id(dummy_config): + soap_api = _build_soap_api_with_mocks(dummy_config) + # The decorator awaits the inner coroutine, so ValueError is raised when awaited. + with pytest.raises(ValueError): + await soap_api.get("customer") + with pytest.raises(ValueError): + await soap_api.get("customer", internalId=1, externalId="x") + + +@pytest.mark.asyncio +async def test_get_with_internal_id_builds_record_ref(dummy_config): + soap_api = _build_soap_api_with_mocks(dummy_config) + # The decorator will try to walk `body.readResponse` on the AsyncMock's + # return value. Bypass by short-circuiting `request` to a non-CompoundValue. + soap_api.request = AsyncMock(return_value=None) # type: ignore[method-assign] + await soap_api.get("customer", internalId=42) + soap_api.Core.RecordRef.assert_called_once_with(type="customer", internalId=42) + soap_api.request.assert_awaited_once() + assert soap_api.request.await_args.args[0] == "get" + + +@pytest.mark.asyncio +async def test_get_with_external_id_builds_record_ref(dummy_config): + soap_api = _build_soap_api_with_mocks(dummy_config) + soap_api.request = AsyncMock(return_value=None) # type: ignore[method-assign] + await soap_api.get("customer", externalId="abc") + soap_api.Core.RecordRef.assert_called_once_with(type="customer", externalId="abc") + + +@pytest.mark.asyncio +async def test_getList_short_circuits_on_no_ids(dummy_config): + soap_api = _build_soap_api_with_mocks(dummy_config) + soap_api.request = AsyncMock() # type: ignore[method-assign] + assert await soap_api.getList("customer") == [] + soap_api.request.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_getList_builds_record_refs_for_each_id(dummy_config): + soap_api = _build_soap_api_with_mocks(dummy_config) + soap_api.request = AsyncMock(return_value=None) # type: ignore[method-assign] + await soap_api.getList("customer", internalIds=[1, 2], externalIds=["e1"]) + # 2 internal + 1 external = 3 RecordRef constructions + assert soap_api.Core.RecordRef.call_count == 3 + soap_api.Messages.GetListRequest.assert_called_once() + + +@pytest.mark.asyncio +async def test_getItemAvailability_short_circuits_on_no_ids(dummy_config): + soap_api = _build_soap_api_with_mocks(dummy_config) + soap_api.request = AsyncMock() # type: ignore[method-assign] + assert await soap_api.getItemAvailability() == [] + soap_api.request.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_getItemAvailability_builds_filter_for_ids(dummy_config): + soap_api = _build_soap_api_with_mocks(dummy_config) + soap_api.request = AsyncMock(return_value=None) # type: ignore[method-assign] + when = datetime(2024, 1, 1, 12, 0, 0) + await soap_api.getItemAvailability( + internalIds=[1, 2], + externalIds=["x"], + lastQtyAvailableChange=when, + ) + soap_api.request.assert_awaited_once() + method, *_ = soap_api.request.await_args.args + assert method == "getItemAvailability" + kw = soap_api.request.await_args.kwargs + item_filters = kw["itemAvailabilityFilter"][0]["item"]["recordRef"] + assert len(item_filters) == 3 + assert kw["itemAvailabilityFilter"][0]["lastQtyAvailableChange"] == when + + +@pytest.mark.asyncio +async def test_getAll_passes_record_type(dummy_config): + soap_api = _build_soap_api_with_mocks(dummy_config) + soap_api.request = AsyncMock(return_value=None) # type: ignore[method-assign] + await soap_api.getAll("subsidiary") + soap_api.Core.GetAllRecord.assert_called_once_with(recordType="subsidiary") + + +@pytest.mark.asyncio +async def test_add_update_upsert_pass_record(dummy_config): + soap_api = _build_soap_api_with_mocks(dummy_config) + soap_api.request = AsyncMock(return_value=None) # type: ignore[method-assign] + record = MagicMock(name="record") + await soap_api.add(record) + await soap_api.update(record) + await soap_api.upsert(record) + methods = [c.args[0] for c in soap_api.request.await_args_list] + assert methods == ["add", "update", "upsert"] + for call in soap_api.request.await_args_list: + assert call.kwargs["record"] is record + + +@pytest.mark.asyncio +async def test_search_passes_search_record_and_headers(dummy_config): + soap_api = _build_soap_api_with_mocks(dummy_config) + soap_api.request = AsyncMock(return_value=None) # type: ignore[method-assign] + record = MagicMock(name="searchRecord") + headers = {"searchPreferences": MagicMock()} + await soap_api.search(record=record, additionalHeaders=headers) + soap_api.request.assert_awaited_once() + assert soap_api.request.await_args.args[0] == "search" + assert soap_api.request.await_args.kwargs["searchRecord"] is record + assert soap_api.request.await_args.kwargs["additionalHeaders"] is headers + + +@pytest.mark.asyncio +async def test_searchMoreWithId_passes_pagination_args(dummy_config): + soap_api = _build_soap_api_with_mocks(dummy_config) + soap_api.request = AsyncMock(return_value=None) # type: ignore[method-assign] + await soap_api.searchMoreWithId(searchId="SID", pageIndex=3) + soap_api.request.assert_awaited_once_with( + "searchMoreWithId", searchId="SID", pageIndex=3 + ) + + +@pytest.mark.asyncio +async def test_request_attaches_passport_and_extra_headers(dummy_config): + """`request` must merge the generated passport with `additionalHeaders`.""" + soap_api = NetSuiteSoapApi(dummy_config) + fake_service = MagicMock() + fake_method = AsyncMock(return_value="result") + fake_service.someOp = fake_method + with patch.object( + type(soap_api), + "service", + new_callable=lambda: property(lambda self: fake_service), + ), patch.object(soap_api, "generate_passport", return_value={"tokenPassport": "P"}): + result = await soap_api.request( + "someOp", + "arg", + additionalHeaders={"extra": "X"}, + kw="v", + ) + assert result == "result" + fake_method.assert_awaited_once() + call = fake_method.await_args + assert call.args == ("arg",) + assert call.kwargs["kw"] == "v" + assert call.kwargs["_soapheaders"] == {"tokenPassport": "P", "extra": "X"} + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def test_to_builtin_serializes_via_zeep_helpers(dummy_config): + soap_api = NetSuiteSoapApi(dummy_config) + sentinel = object() + with patch( + "netsuite.soap_api.helpers.zeep.helpers.serialize_object", + return_value=sentinel, + ) as mock_ser: + out = soap_api.to_builtin("input") + assert out is sentinel + mock_ser.assert_called_once_with("input", target_cls=dict) + + +def test_with_timeout_uses_transport_settings(dummy_config): + soap_api = NetSuiteSoapApi(dummy_config) + fake_settings = MagicMock() + fake_settings.return_value.__enter__ = MagicMock() + fake_settings.return_value.__exit__ = MagicMock(return_value=None) + fake_transport = MagicMock(settings=fake_settings) + with patch.object( + type(soap_api), + "transport", + new_callable=lambda: property(lambda self: fake_transport), + ): + with soap_api.with_timeout(42): + pass + fake_settings.assert_called_once_with(timeout=42) + + +# --------------------------------------------------------------------------- +# Dependency check +# --------------------------------------------------------------------------- + + +def test_missing_zeep_raises_runtime_error(dummy_config): + with patch.object( + NetSuiteSoapApi, "_has_required_dependencies", return_value=False + ): + with pytest.raises(RuntimeError, match="soap_api"): + NetSuiteSoapApi(dummy_config) diff --git a/tests/test_soap_api_decorators.py b/tests/test_soap_api_decorators.py new file mode 100644 index 0000000..bb286f7 --- /dev/null +++ b/tests/test_soap_api_decorators.py @@ -0,0 +1,124 @@ +"""Tests for the `WebServiceCall` decorator. + +Regression tests for jacobsvante/netsuite#45 — the decorator previously +checked `isinstance(response, zeep.xsd.ComplexType)`, which is the schema +definition class. Real SOAP responses are `zeep.xsd.CompoundValue` instances, +so the check was always False and status validation / extraction were +silently skipped. The decorator was also a synchronous wrapper around async +SOAP methods, so even with the right isinstance check it would have received +a coroutine instead of a response. +""" + +import pytest + +from netsuite.soap_api.exceptions import NetsuiteResponseError +from netsuite.soap_api.zeep import ZEEP_INSTALLED + +pytestmark = pytest.mark.skipif(not ZEEP_INSTALLED, reason="Requires zeep") + +if ZEEP_INSTALLED: + from zeep.xsd.valueobjects import CompoundValue + + from netsuite.soap_api.decorators import WebServiceCall + + +class _FakeResponse(CompoundValue): + """A minimal CompoundValue-shaped object usable as both attribute container and mapping.""" + + def __init__(self, **attrs): + # Bypass CompoundValue.__init__ which expects an XSD type + object.__setattr__(self, "_attrs", attrs) + + def __getattr__(self, name): + attrs = object.__getattribute__(self, "_attrs") + if name in attrs: + return attrs[name] + raise AttributeError(name) + + def __getitem__(self, key): + return object.__getattribute__(self, "_attrs")[key] + + def __iter__(self): + return iter(object.__getattribute__(self, "_attrs").values()) + + +def _ok_status(): + return {"isSuccess": True, "statusDetail": []} + + +def _err_status(detail="boom"): + return {"isSuccess": False, "statusDetail": detail} + + +@pytest.mark.asyncio +async def test_async_decorator_unwraps_path_and_extracts(): + """Decorator must descend `path` and apply `extract` for async SOAP methods.""" + + inner = _FakeResponse(record={"id": 42}, status=_ok_status()) + outer = _FakeResponse(body=_FakeResponse(readResponse=inner)) + + @WebServiceCall( + "body.readResponse", + extract=lambda resp: resp["record"], + ) + async def fake_get(self): + return outer + + result = await fake_get(object()) + assert result == {"id": 42} + + +@pytest.mark.asyncio +async def test_async_decorator_raises_on_failed_status(): + inner = _FakeResponse(record=None, status=_err_status("nope")) + outer = _FakeResponse(body=_FakeResponse(readResponse=inner)) + + @WebServiceCall("body.readResponse") + async def fake_get(self): + return outer + + with pytest.raises(NetsuiteResponseError): + await fake_get(object()) + + +@pytest.mark.asyncio +async def test_async_decorator_passes_through_non_soap_values(): + """If the wrapped function returns a non-CompoundValue (e.g. early-out []), + the decorator should return it untouched without trying to walk `path`.""" + + @WebServiceCall("body.readResponseList.readResponse") + async def fake_getList(self): + return [] + + assert await fake_getList(object()) == [] + + +@pytest.mark.asyncio +async def test_async_decorator_returns_default_when_path_missing(): + outer = _FakeResponse(body=_FakeResponse()) # no `getItemAvailabilityResult` + + @WebServiceCall( + "body.getItemAvailabilityResult", + extract=lambda resp: resp["itemAvailabilityList"]["itemAvailability"], + default=[], + ) + async def fake_getItemAvailability(self): + return outer + + assert await fake_getItemAvailability(object()) == [] + + +def test_sync_decorator_still_works(): + """Sync functions decorated with WebServiceCall should also process responses.""" + + inner = _FakeResponse(record={"id": 7}, status=_ok_status()) + outer = _FakeResponse(body=_FakeResponse(readResponse=inner)) + + @WebServiceCall( + "body.readResponse", + extract=lambda resp: resp["record"], + ) + def fake_get_sync(self): + return outer + + assert fake_get_sync(object()) == {"id": 7} diff --git a/tests/test_soap_api_decorators_edge_cases.py b/tests/test_soap_api_decorators_edge_cases.py new file mode 100644 index 0000000..b8de858 --- /dev/null +++ b/tests/test_soap_api_decorators_edge_cases.py @@ -0,0 +1,82 @@ +"""Edge-case tests for `WebServiceCall` not covered in +`test_soap_api_decorators.py`. + +Focus: the list-status code path (TypeError when indexing the response with +"status" because it's iterable rather than a mapping), and the unset-default +re-raise.""" + +import pytest + +from netsuite.soap_api.exceptions import NetsuiteResponseError +from netsuite.soap_api.zeep import ZEEP_INSTALLED + +pytestmark = pytest.mark.skipif(not ZEEP_INSTALLED, reason="Requires zeep") + +if ZEEP_INSTALLED: + from zeep.xsd.valueobjects import CompoundValue + + from netsuite.soap_api.decorators import WebServiceCall + + +class _AttrAndItemResponse(CompoundValue): + def __init__(self, **attrs): + object.__setattr__(self, "_attrs", attrs) + + def __getattr__(self, name): + attrs = object.__getattribute__(self, "_attrs") + if name in attrs: + return attrs[name] + raise AttributeError(name) + + def __getitem__(self, key): + return object.__getattribute__(self, "_attrs")[key] + + +@pytest.mark.asyncio +async def test_decorator_status_via_list_iteration_when_indexing_raises(): + """When path traversal lands on a plain list (e.g. a record list), the + decorator's `response['status']` raises TypeError. It must then iterate + the list and pull `status` from the first record.""" + + record = _AttrAndItemResponse(status={"isSuccess": True, "statusDetail": []}) + # `body.records` resolves to a plain Python list — `list['status']` + # raises TypeError, exercising the fallback iteration path. + outer = _AttrAndItemResponse(body=_AttrAndItemResponse(records=[record])) + + @WebServiceCall("body.records", extract=lambda resp: list(resp)) + async def fake_op(self): + return outer + + out = await fake_op(object()) + assert out == [record] + + +@pytest.mark.asyncio +async def test_decorator_status_via_list_iteration_propagates_error(): + record = _AttrAndItemResponse( + status={"isSuccess": False, "statusDetail": "first record failed"} + ) + outer = _AttrAndItemResponse(body=_AttrAndItemResponse(records=[record])) + + @WebServiceCall("body.records") + async def fake_op(self): + return outer + + with pytest.raises(NetsuiteResponseError): + await fake_op(object()) + + +@pytest.mark.asyncio +async def test_decorator_reraises_attribute_error_when_default_unset(): + """If walking `path` hits an AttributeError and no `default` was + supplied, the original AttributeError should propagate rather than be + silently swallowed.""" + + outer = _AttrAndItemResponse(body=_AttrAndItemResponse()) # no `readResponse` + + @WebServiceCall("body.readResponse") # no default + async def fake_get(self): + return outer + + with pytest.raises(AttributeError): + await fake_get(object()) diff --git a/tests/test_soap_api_deprecation.py b/tests/test_soap_api_deprecation.py new file mode 100644 index 0000000..e0f62f0 --- /dev/null +++ b/tests/test_soap_api_deprecation.py @@ -0,0 +1,33 @@ +"""Verify NetSuiteSoapApi emits a DeprecationWarning pointing users at +OAuth 2.0. NetSuite is gradually removing SOAP Web Services (2025.2 is the +last planned endpoint), and the warning is the user-facing nudge to migrate.""" + +import warnings + +import pytest + +from netsuite import NetSuiteSoapApi +from netsuite.soap_api.client import SOAP_DEPRECATION_MESSAGE +from netsuite.soap_api.zeep import ZEEP_INSTALLED + +pytestmark = pytest.mark.skipif(not ZEEP_INSTALLED, reason="Requires zeep") + + +def test_soap_api_init_emits_deprecation_warning(dummy_config): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + NetSuiteSoapApi(dummy_config) + deprecation_warnings = [ + w for w in caught if issubclass(w.category, DeprecationWarning) + ] + assert len(deprecation_warnings) == 1 + assert "2025.2" in str(deprecation_warnings[0].message) + assert "OAuth 2.0" in str(deprecation_warnings[0].message) + + +def test_soap_deprecation_message_mentions_replacement(dummy_config): + """The message should point readers at the OAuth2 config class so they + have a concrete next step, not just a generic warning.""" + assert "OAuth2ClientCredentialsAuth" in SOAP_DEPRECATION_MESSAGE + assert "REST API" in SOAP_DEPRECATION_MESSAGE + assert "2025.2" in SOAP_DEPRECATION_MESSAGE diff --git a/tests/test_soap_api_passport.py b/tests/test_soap_api_passport.py new file mode 100644 index 0000000..604efde --- /dev/null +++ b/tests/test_soap_api_passport.py @@ -0,0 +1,131 @@ +"""Tests for SOAP TokenPassport — signature/nonce/timestamp generation, and +the `make` factory that wraps it for outbound headers.""" + +import re +from unittest.mock import MagicMock + +import pytest + +from netsuite.config import Config +from netsuite.soap_api.passport import Passport, TokenPassport +from netsuite.soap_api.passport import make as make_passport +from netsuite.soap_api.zeep import ZEEP_INSTALLED + +pytestmark = pytest.mark.skipif(not ZEEP_INSTALLED, reason="Requires zeep") + + +def _make_passport(config): + auth = config.auth + fake_ns = MagicMock() + return TokenPassport( + fake_ns, + account=config.account, + consumer_key=auth.consumer_key, + consumer_secret=auth.consumer_secret, + token_id=auth.token_id, + token_secret=auth.token_secret, + ) + + +def test_base_passport_get_element_is_abstract(): + with pytest.raises(NotImplementedError): + Passport().get_element() + + +def test_generate_timestamp_is_unix_seconds(dummy_config): + passport = _make_passport(dummy_config) + ts = passport._generate_timestamp() + # Sanity-check: NetSuite's accepted timestamps are seconds since epoch. + assert ts.isdigit() + assert int(ts) > 1_500_000_000 # post-2017 + + +def test_generate_nonce_is_digits_with_default_length(dummy_config): + passport = _make_passport(dummy_config) + nonce = passport._generate_nonce() + assert re.fullmatch(r"\d{20}", nonce) + + +def test_generate_nonce_respects_custom_length(dummy_config): + passport = _make_passport(dummy_config) + nonce = passport._generate_nonce(length=8) + assert re.fullmatch(r"\d{8}", nonce) + + +def test_signature_message_components(dummy_config): + passport = _make_passport(dummy_config) + msg = passport._get_signature_message(nonce="N", timestamp="T") + assert msg == "&".join( + [ + dummy_config.account, + dummy_config.auth.consumer_key, + dummy_config.auth.token_id, + "N", + "T", + ] + ) + + +def test_signature_key_combines_secrets(dummy_config): + passport = _make_passport(dummy_config) + key = passport._get_signature_key() + assert ( + key == f"{dummy_config.auth.consumer_secret}&{dummy_config.auth.token_secret}" + ) + + +def test_signature_value_changes_when_inputs_change(dummy_config): + passport = _make_passport(dummy_config) + assert passport._get_signature_value("a", "1") != passport._get_signature_value( + "a", "2" + ) + assert passport._get_signature_value("a", "1") != passport._get_signature_value( + "b", "1" + ) + + +def test_get_signature_uses_core_token_passport_signature(dummy_config): + """`_get_signature` should construct a `Core.TokenPassportSignature` with + the HMAC-SHA256 algorithm marker.""" + passport = _make_passport(dummy_config) + passport.ns.Core.TokenPassportSignature = MagicMock(return_value="sig-obj") + result = passport._get_signature(nonce="N", timestamp="T") + passport.ns.Core.TokenPassportSignature.assert_called_once() + args, kwargs = passport.ns.Core.TokenPassportSignature.call_args + assert kwargs == {"algorithm": "HMAC-SHA256"} + # The first positional is the base64 signature value. + assert isinstance(args[0], str) + assert result == "sig-obj" + + +def test_get_element_returns_token_passport_with_all_fields(dummy_config): + passport = _make_passport(dummy_config) + passport.ns.Core.TokenPassportSignature = MagicMock(return_value="sig") + passport.ns.Core.TokenPassport = MagicMock(return_value="token-passport") + result = passport.get_element() + passport.ns.Core.TokenPassport.assert_called_once() + kwargs = passport.ns.Core.TokenPassport.call_args.kwargs + assert kwargs["account"] == dummy_config.account + assert kwargs["consumerKey"] == dummy_config.auth.consumer_key + assert kwargs["token"] == dummy_config.auth.token_id + assert kwargs["nonce"].isdigit() + assert kwargs["timestamp"].isdigit() + assert kwargs["signature"] == "sig" + assert result == "token-passport" + + +def test_make_returns_token_passport_dict_for_token_auth(dummy_config): + fake_ns = MagicMock() + fake_ns.Core.TokenPassport.return_value = "tp" + fake_ns.Core.TokenPassportSignature.return_value = "sig" + out = make_passport(fake_ns, dummy_config) + assert "tokenPassport" in out + + +def test_make_rejects_username_password_auth(): + config = Config( + account="123456_SB1", + auth={"username": "u", "password": "p"}, + ) + with pytest.raises(NotImplementedError): + make_passport(MagicMock(), config) diff --git a/tests/test_soap_api_transports.py b/tests/test_soap_api_transports.py new file mode 100644 index 0000000..b82fc76 --- /dev/null +++ b/tests/test_soap_api_transports.py @@ -0,0 +1,94 @@ +"""Tests for `AsyncNetSuiteTransport` — the zeep transport that forces +each request to the account-specific NetSuite subdomain.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from netsuite.soap_api.transports import AsyncNetSuiteTransport +from netsuite.soap_api.zeep import ZEEP_INSTALLED + +pytestmark = pytest.mark.skipif(not ZEEP_INSTALLED, reason="Requires zeep") + + +def test_init_extracts_base_url_from_wsdl_url(): + transport = AsyncNetSuiteTransport( + "https://123-sb1.suitetalk.api.netsuite.com/wsdl/v2021_1_0/netsuite.wsdl", + ) + assert transport._netsuite_base_url == "https://123-sb1.suitetalk.api.netsuite.com" + + +def test_init_handles_url_without_path(): + transport = AsyncNetSuiteTransport("https://example.com") + assert transport._netsuite_base_url == "https://example.com" + + +def test_fix_address_swaps_default_host_for_account_host(): + transport = AsyncNetSuiteTransport( + "https://123-sb1.suitetalk.api.netsuite.com/wsdl/v2021_1_0/netsuite.wsdl", + ) + assert ( + transport._fix_address( + "https://webservices.netsuite.com/services/NetSuitePort_2021_1" + ) + == "https://123-sb1.suitetalk.api.netsuite.com/services/NetSuitePort_2021_1" + ) + + +def test_fix_address_preserves_query_string(): + transport = AsyncNetSuiteTransport( + "https://acct.suitetalk.api.netsuite.com/wsdl/v2021_1_0/netsuite.wsdl", + ) + fixed = transport._fix_address( + "https://webservices.netsuite.com/services/NetSuitePort_2021_1?wsdl" + ) + assert ( + fixed + == "https://acct.suitetalk.api.netsuite.com/services/NetSuitePort_2021_1?wsdl" + ) + + +@pytest.mark.asyncio +async def test_get_passes_through_fixed_address(): + transport = AsyncNetSuiteTransport( + "https://acct.suitetalk.api.netsuite.com/wsdl/v.wsdl", + ) + with patch( + "zeep.transports.AsyncTransport.get", + new_callable=AsyncMock, + return_value="resp", + ) as parent_get: + result = await transport.get( + "https://webservices.netsuite.com/services/foo", + params={"k": "v"}, + headers={"H": "1"}, + ) + assert result == "resp" + parent_get.assert_awaited_once_with( + "https://acct.suitetalk.api.netsuite.com/services/foo", + {"k": "v"}, + {"H": "1"}, + ) + + +@pytest.mark.asyncio +async def test_post_passes_through_fixed_address(): + transport = AsyncNetSuiteTransport( + "https://acct.suitetalk.api.netsuite.com/wsdl/v.wsdl", + ) + with patch( + "zeep.transports.AsyncTransport.post", + new_callable=AsyncMock, + return_value="resp", + ) as parent_post: + result = await transport.post( + "https://webservices.netsuite.com/services/foo", + "", + {"H": "1"}, + ) + assert result == "resp" + parent_post.assert_awaited_once_with( + "https://acct.suitetalk.api.netsuite.com/services/foo", + "", + {"H": "1"}, + )