Skip to content

Commit

Permalink
4.x gnp (#300)
Browse files Browse the repository at this point in the history
* add camara auth module

* start adding gnp sim swap

* adding new gnp packages

* update camara auth

* add network packages

* finish adding network apis and prepare for release

* adjust verify v2 channel timeout and prepare for release
  • Loading branch information
maxkahan authored Jun 21, 2024
1 parent 280ebf2 commit f65afe5
Show file tree
Hide file tree
Showing 49 changed files with 758 additions and 23 deletions.
3 changes: 3 additions & 0 deletions http_client/CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# 1.4.0
- Add new `oauth2` logic for calling APIs that require Oauth

# 1.3.1
- Update minimum dependency version

Expand Down
4 changes: 2 additions & 2 deletions http_client/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
[project]
name = "vonage-http-client"
version = "1.3.1"
version = "1.4.0"
description = "An HTTP client for making requests to Vonage APIs."
readme = "README.md"
authors = [{ name = "Vonage", email = "[email protected]" }]
requires-python = ">=3.8"
dependencies = [
"vonage-utils>=1.1.1",
"vonage-utils>=1.1.2",
"vonage-jwt>=1.1.1",
"requests>=2.27.0",
"typing-extensions>=4.9.0",
Expand Down
12 changes: 8 additions & 4 deletions http_client/src/vonage_http_client/http_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,11 +130,12 @@ def post(
host: str,
request_path: str = '',
params: dict = None,
auth_type: Literal['jwt', 'basic', 'body', 'signature'] = 'jwt',
sent_data_type: Literal['json', 'data'] = 'json',
auth_type: Literal['jwt', 'basic', 'body', 'signature', 'oauth2'] = 'jwt',
sent_data_type: Literal['json', 'form', 'query-params'] = 'json',
token: Optional[str] = None,
) -> Union[dict, None]:
return self.make_request(
'POST', host, request_path, params, auth_type, sent_data_type
'POST', host, request_path, params, auth_type, sent_data_type, token
)

def get(
Expand Down Expand Up @@ -192,8 +193,9 @@ def make_request(
host: str,
request_path: str = '',
params: Optional[dict] = None,
auth_type: Literal['jwt', 'basic', 'body', 'signature'] = 'jwt',
auth_type: Literal['jwt', 'basic', 'body', 'signature', 'oauth2'] = 'jwt',
sent_data_type: Literal['json', 'form', 'query_params'] = 'json',
token: Optional[str] = None,
):
url = f'https://{host}{request_path}'
logger.debug(
Expand All @@ -206,6 +208,8 @@ def make_request(
elif auth_type == 'body':
params['api_key'] = self._auth.api_key
params['api_secret'] = self._auth.api_secret
elif auth_type == 'oauth2':
self._headers['Authorization'] = f'Bearer {token}'
elif auth_type == 'signature':
params['api_key'] = self._auth.api_key
params['sig'] = self._auth.sign_params(params)
Expand Down
16 changes: 16 additions & 0 deletions network_auth/BUILD
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
resource(name='pyproject', source='pyproject.toml')
file(name='readme', source='README.md')

files(sources=['tests/data/*'])

python_distribution(
name='vonage-network-auth',
dependencies=[
':pyproject',
':readme',
'network_auth/src/vonage_network_auth',
],
provides=python_artifact(),
generate_setup=False,
repositories=['@pypi'],
)
2 changes: 2 additions & 0 deletions network_auth/CHANGES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# 0.1.0b0
- Initial upload
36 changes: 36 additions & 0 deletions network_auth/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Vonage Network API Authentication Client

This package (`vonage-network-auth`) provides a client for authenticating Network APIs that require Oauth2 authentcation. Using it, it is possible to generate authenticated JWTs for use with GNP APIs, e.g. Sim Swap, Number Verification.

This package is intended to be used as part of an SDK, accessing required methods through the SDK instead of directly. Thus, it doesn't require manual installation or configuration unless you're using this package independently of an SDK.

For full API documentation, refer to the [Vonage developer documentation](https://developer.vonage.com).

Please note this package is in beta.

## Installation

Install from the Python Package Index with pip:

```bash
pip install vonage-network-auth
```

## Usage

### Create a `NetworkAuth` Object

```python
from vonage_network_auth import NetworkAuth
from vonage_http_client import HttpClient, Auth

network_auth = NetworkAuth(HttpClient(Auth(application_id='application-id', private_key='private-key')))
```

### Generate an Authenticated Access Token

```python
token = network_auth.get_oauth2_user_token(
number='447700900000', scope='dpv:FraudPreventionAndDetection#check-sim-swap'
)
```
25 changes: 25 additions & 0 deletions network_auth/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
[project]
name = "vonage-network-auth"
version = "0.1.0b0"
description = "Package for working with Network APIs that require Oauth2 in Python."
readme = "README.md"
authors = [{ name = "Vonage", email = "[email protected]" }]
requires-python = ">=3.8"
dependencies = ["vonage-http-client>=1.4.0", "vonage-utils>=1.1.2"]
classifiers = [
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"License :: OSI Approved :: Apache Software License",
]

[project.urls]
Homepage = "https://github.com/Vonage/vonage-python-sdk"

[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
1 change: 1 addition & 0 deletions network_auth/src/vonage_network_auth/BUILD
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
python_sources()
4 changes: 4 additions & 0 deletions network_auth/src/vonage_network_auth/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from .network_auth import NetworkAuth
from .responses import OidcResponse, TokenResponse

__all__ = ['NetworkAuth', 'OidcResponse', 'TokenResponse']
91 changes: 91 additions & 0 deletions network_auth/src/vonage_network_auth/network_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
from pydantic import validate_call
from vonage_http_client.http_client import HttpClient

from .responses import OidcResponse, TokenResponse


class NetworkAuth:
"""Class containing methods for authenticating Network APIs following Camara standards."""

def __init__(self, http_client: HttpClient):
self._http_client = http_client
self._host = 'api-eu.vonage.com'
self._auth_type = 'jwt'
self._sent_data_type = 'form'

@property
def http_client(self) -> HttpClient:
"""The HTTP client used to make requests to the Network Auth API.
Returns:
HttpClient: The HTTP client used to make requests to the Network Auth API.
"""
return self._http_client

@validate_call
def get_oauth2_user_token(self, number: str, scope: str) -> str:
"""Get an OAuth2 user token for a given number and scope.
Args:
number (str): The phone number to authenticate.
scope (str): The scope of the token.
Returns:
str: The OAuth2 user token.
"""
oidc_response = self.make_oidc_request(number, scope)
token_response = self.request_access_token(oidc_response.auth_req_id)
return token_response.access_token

@validate_call
def make_oidc_request(self, number: str, scope: str) -> OidcResponse:
"""Make an OIDC request to authenticate a user.
Args:
number (str): The phone number to authenticate.
scope (str): The scope of the token.
Returns:
OidcResponse: A response containing the authentication request ID.
"""
number = self._ensure_plus_prefix(number)
params = {'login_hint': number, 'scope': scope}

response = self._http_client.post(
self._host,
'/oauth2/bc-authorize',
params,
self._auth_type,
self._sent_data_type,
)
return OidcResponse(**response)

@validate_call
def request_access_token(
self, auth_req_id: str, grant_type: str = 'urn:openid:params:grant-type:ciba'
) -> TokenResponse:
"""Request a Camara access token using an authentication request ID given as a response to
an OIDC request."""
params = {'auth_req_id': auth_req_id, 'grant_type': grant_type}

response = self._http_client.post(
self._host,
'/oauth2/token',
params,
self._auth_type,
self._sent_data_type,
)
return TokenResponse(**response)

def _ensure_plus_prefix(self, number: str) -> str:
"""Ensure that the number has a plus prefix.
Args:
number (str): The phone number to check.
Returns:
str: The phone number with a plus prefix.
"""
if number.startswith('+'):
return number
return f'+{number}'
16 changes: 16 additions & 0 deletions network_auth/src/vonage_network_auth/responses.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from typing import Optional

from pydantic import BaseModel


class OidcResponse(BaseModel):
auth_req_id: str
expires_in: int
interval: Optional[int] = None


class TokenResponse(BaseModel):
access_token: str
token_type: Optional[str] = None
refresh_token: Optional[str] = None
expires_in: Optional[int] = None
1 change: 1 addition & 0 deletions network_auth/tests/BUILD
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
python_tests(dependencies=['network_auth', 'testutils'])
5 changes: 5 additions & 0 deletions network_auth/tests/data/oidc_request.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"auth_req_id": "arid/8b0d35f3-4627-487c-a776-aegtdsf4rsd2",
"expires_in": 300,
"interval": 0
}
6 changes: 6 additions & 0 deletions network_auth/tests/data/oidc_request_permissions_error.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"type": "https://developer.vonage.com/api-errors#invalid-param",
"title": "Bad Request",
"detail": "No Network Application associated with Vonage Application: 29f760f8-7ce1-46c9-ade3-f2dedee4ed5f",
"instance": "b45ae630-7621-42b0-8ff0-6c1ad98e6e32"
}
5 changes: 5 additions & 0 deletions network_auth/tests/data/token_request.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"access_token": "eyJhbGciOiJSUzI1NiIsImprdSI6Imh0dHBzOi8vYW51YmlzLWNlcnRzLWMxLWV1dzEucHJvZC52MS52b25hZ2VuZXR3b3Jrcy5uZXQvandrcyIsImtpZCI6IkNOPVZvbmFnZSAxdmFwaWd3IEludGVybmFsIENBOjoxOTUxODQ2ODA3NDg1NTYwNjYzODY3MTM0NjE2MjU2MTU5MjU2NDkiLCJ0eXAiOiJKV1QiLCJ4NXUiOiJodHRwczovL2FudWJpcy1jZXJ0cy1jMS1ldXcxLnByb2QudjEudm9uYWdlbmV0d29ya3MubmV0L3YxL2NlcnRzLzA4NjliNDMyZTEzZmIyMzcwZTk2ZGI4YmUxMDc4MjJkIn0.eyJwcmluY2lwYWwiOnsiYXBpS2V5IjoiNGI1MmMwMGUiLCJhcHBsaWNhdGlvbklkIjoiMmJlZTViZWQtNmZlZS00ZjM2LTkxNmQtNWUzYjRjZDI1MjQzIiwibWFzdGVyQWNjb3VudElkIjoiNGI1MmMwMGUiLCJjYXBhYmlsaXRpZXMiOlsibmV0d29yay1hcGktZmVhdHVyZXMiXSwiZXh0cmFDb25maWciOnsiY2FtYXJhU3RhdGUiOiJmb0ZyQndnOFNmeGMydnd2S1o5Y3UrMlgrT0s1K2FvOWhJTTVGUGZMQ1dOeUlMTHR3WmY1dFRKbDdUc1p4QnY4QWx3aHM2bFNWcGVvVkhoWngvM3hUenFRWVkwcHpIZE5XL085ZEdRN1RKOE9sU1lDdTFYYXFEcnNFbEF4WEJVcUpGdnZTTkp5a1A5ZDBYWVN4ajZFd0F6UUFsNGluQjE1c3VMRFNsKy82U1FDa29Udnpld0tvcFRZb0F5MVg2dDJVWXdEVWFDNjZuOS9kVWxIemN3V0NGK3QwOGNReGxZVUxKZyt3T0hwV2xvWGx1MGc3REx0SCtHd0pvRGJoYnMyT2hVY3BobGZqajBpeHQ1OTRsSG5sQ1NYNkZrMmhvWEhKUW01S3JtOVBKSmttK0xTRjVsRTd3NUxtWTRvYTFXSGpkY0dwV1VsQlNQY000YnprOGU0bVE9PSJ9fSwiZmVkZXJhdGVkQXNzZXJ0aW9ucyI6e30sImF1ZCI6ImFwaS1ldS52b25hZ2UuY29tIiwiZXhwIjoxNzE3MDkyODY4LCJqdGkiOiJmNDZhYTViOC1hODA2LTRjMzctODQyMS02OGYwMzJjNDlhMWYiLCJpYXQiOjE3MTcwOTE5NzAsImlzcyI6IlZJQU0tSUFQIiwibmJmIjoxNzE3MDkxOTU1fQ.iLUbyDPR1HGLKh29fy6fqK65Q1O7mjWOletAEPJD4eu7gb0E85EL4M9R7ckJq5lIvgedQt3vBheTaON9_u-VYjMqo8ulPoEoGUDHbOzNbs4MmCW0_CRdDPGyxnUhvcbuJhPgnEHxmfHjJBljncUnk-Z7XCgyNajBNXeQQnHkRF_6NMngxJ-qjjhqbYL0VsF_JS7-TXxixNL0KAFl0SeN2DjkfwRBCclP-69CTExDjyOvouAcchqi-6ZYj_tXPCrTADuzUrQrW8C5nHp2-XjWJSFKzyvi48n8V1U6KseV-eYzBzvy7bJf0tRMX7G6gctTYq3DxdC_eXvXlnp1zx16mg",
"token_type": "bearer",
"expires_in": 29
}
111 changes: 111 additions & 0 deletions network_auth/tests/test_network_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
from os.path import abspath

import responses
from pytest import raises
from vonage_http_client.errors import HttpRequestError
from vonage_http_client.http_client import HttpClient
from vonage_network_auth import NetworkAuth
from vonage_network_auth.responses import OidcResponse

from testutils import build_response, get_mock_jwt_auth

path = abspath(__file__)


network_auth = NetworkAuth(HttpClient(get_mock_jwt_auth()))


def test_http_client_property():
http_client = network_auth.http_client
assert isinstance(http_client, HttpClient)


@responses.activate
def test_oidc_request():
build_response(
path,
'POST',
'https://api-eu.vonage.com/oauth2/bc-authorize',
'oidc_request.json',
)

response = network_auth.make_oidc_request(
number='447700900000',
scope='dpv:FraudPreventionAndDetection#check-sim-swap',
)

assert response.auth_req_id == 'arid/8b0d35f3-4627-487c-a776-aegtdsf4rsd2'
assert response.expires_in == 300
assert response.interval == 0


@responses.activate
def test_request_access_token():
build_response(
path,
'POST',
'https://api-eu.vonage.com/oauth2/token',
'token_request.json',
)

oidc_response_dict = {
'auth_req_id': '0dadaeb4-7c79-4d39-b4b0-5a6cc08bf537',
'expires_in': '120',
'interval': '2',
}
oidc_response = OidcResponse(**oidc_response_dict)
response = network_auth.request_access_token(oidc_response.auth_req_id)

assert (
response.access_token
== 'eyJhbGciOiJSUzI1NiIsImprdSI6Imh0dHBzOi8vYW51YmlzLWNlcnRzLWMxLWV1dzEucHJvZC52MS52b25hZ2VuZXR3b3Jrcy5uZXQvandrcyIsImtpZCI6IkNOPVZvbmFnZSAxdmFwaWd3IEludGVybmFsIENBOjoxOTUxODQ2ODA3NDg1NTYwNjYzODY3MTM0NjE2MjU2MTU5MjU2NDkiLCJ0eXAiOiJKV1QiLCJ4NXUiOiJodHRwczovL2FudWJpcy1jZXJ0cy1jMS1ldXcxLnByb2QudjEudm9uYWdlbmV0d29ya3MubmV0L3YxL2NlcnRzLzA4NjliNDMyZTEzZmIyMzcwZTk2ZGI4YmUxMDc4MjJkIn0.eyJwcmluY2lwYWwiOnsiYXBpS2V5IjoiNGI1MmMwMGUiLCJhcHBsaWNhdGlvbklkIjoiMmJlZTViZWQtNmZlZS00ZjM2LTkxNmQtNWUzYjRjZDI1MjQzIiwibWFzdGVyQWNjb3VudElkIjoiNGI1MmMwMGUiLCJjYXBhYmlsaXRpZXMiOlsibmV0d29yay1hcGktZmVhdHVyZXMiXSwiZXh0cmFDb25maWciOnsiY2FtYXJhU3RhdGUiOiJmb0ZyQndnOFNmeGMydnd2S1o5Y3UrMlgrT0s1K2FvOWhJTTVGUGZMQ1dOeUlMTHR3WmY1dFRKbDdUc1p4QnY4QWx3aHM2bFNWcGVvVkhoWngvM3hUenFRWVkwcHpIZE5XL085ZEdRN1RKOE9sU1lDdTFYYXFEcnNFbEF4WEJVcUpGdnZTTkp5a1A5ZDBYWVN4ajZFd0F6UUFsNGluQjE1c3VMRFNsKy82U1FDa29Udnpld0tvcFRZb0F5MVg2dDJVWXdEVWFDNjZuOS9kVWxIemN3V0NGK3QwOGNReGxZVUxKZyt3T0hwV2xvWGx1MGc3REx0SCtHd0pvRGJoYnMyT2hVY3BobGZqajBpeHQ1OTRsSG5sQ1NYNkZrMmhvWEhKUW01S3JtOVBKSmttK0xTRjVsRTd3NUxtWTRvYTFXSGpkY0dwV1VsQlNQY000YnprOGU0bVE9PSJ9fSwiZmVkZXJhdGVkQXNzZXJ0aW9ucyI6e30sImF1ZCI6ImFwaS1ldS52b25hZ2UuY29tIiwiZXhwIjoxNzE3MDkyODY4LCJqdGkiOiJmNDZhYTViOC1hODA2LTRjMzctODQyMS02OGYwMzJjNDlhMWYiLCJpYXQiOjE3MTcwOTE5NzAsImlzcyI6IlZJQU0tSUFQIiwibmJmIjoxNzE3MDkxOTU1fQ.iLUbyDPR1HGLKh29fy6fqK65Q1O7mjWOletAEPJD4eu7gb0E85EL4M9R7ckJq5lIvgedQt3vBheTaON9_u-VYjMqo8ulPoEoGUDHbOzNbs4MmCW0_CRdDPGyxnUhvcbuJhPgnEHxmfHjJBljncUnk-Z7XCgyNajBNXeQQnHkRF_6NMngxJ-qjjhqbYL0VsF_JS7-TXxixNL0KAFl0SeN2DjkfwRBCclP-69CTExDjyOvouAcchqi-6ZYj_tXPCrTADuzUrQrW8C5nHp2-XjWJSFKzyvi48n8V1U6KseV-eYzBzvy7bJf0tRMX7G6gctTYq3DxdC_eXvXlnp1zx16mg'
)
assert response.token_type == 'bearer'
assert response.expires_in == 29


@responses.activate
def test_whole_oauth2_flow():
build_response(
path,
'POST',
'https://api-eu.vonage.com/oauth2/bc-authorize',
'oidc_request.json',
)
build_response(
path,
'POST',
'https://api-eu.vonage.com/oauth2/token',
'token_request.json',
)

access_token = network_auth.get_oauth2_user_token(
number='447700900000', scope='dpv:FraudPreventionAndDetection#check-sim-swap'
)
assert (
access_token
== 'eyJhbGciOiJSUzI1NiIsImprdSI6Imh0dHBzOi8vYW51YmlzLWNlcnRzLWMxLWV1dzEucHJvZC52MS52b25hZ2VuZXR3b3Jrcy5uZXQvandrcyIsImtpZCI6IkNOPVZvbmFnZSAxdmFwaWd3IEludGVybmFsIENBOjoxOTUxODQ2ODA3NDg1NTYwNjYzODY3MTM0NjE2MjU2MTU5MjU2NDkiLCJ0eXAiOiJKV1QiLCJ4NXUiOiJodHRwczovL2FudWJpcy1jZXJ0cy1jMS1ldXcxLnByb2QudjEudm9uYWdlbmV0d29ya3MubmV0L3YxL2NlcnRzLzA4NjliNDMyZTEzZmIyMzcwZTk2ZGI4YmUxMDc4MjJkIn0.eyJwcmluY2lwYWwiOnsiYXBpS2V5IjoiNGI1MmMwMGUiLCJhcHBsaWNhdGlvbklkIjoiMmJlZTViZWQtNmZlZS00ZjM2LTkxNmQtNWUzYjRjZDI1MjQzIiwibWFzdGVyQWNjb3VudElkIjoiNGI1MmMwMGUiLCJjYXBhYmlsaXRpZXMiOlsibmV0d29yay1hcGktZmVhdHVyZXMiXSwiZXh0cmFDb25maWciOnsiY2FtYXJhU3RhdGUiOiJmb0ZyQndnOFNmeGMydnd2S1o5Y3UrMlgrT0s1K2FvOWhJTTVGUGZMQ1dOeUlMTHR3WmY1dFRKbDdUc1p4QnY4QWx3aHM2bFNWcGVvVkhoWngvM3hUenFRWVkwcHpIZE5XL085ZEdRN1RKOE9sU1lDdTFYYXFEcnNFbEF4WEJVcUpGdnZTTkp5a1A5ZDBYWVN4ajZFd0F6UUFsNGluQjE1c3VMRFNsKy82U1FDa29Udnpld0tvcFRZb0F5MVg2dDJVWXdEVWFDNjZuOS9kVWxIemN3V0NGK3QwOGNReGxZVUxKZyt3T0hwV2xvWGx1MGc3REx0SCtHd0pvRGJoYnMyT2hVY3BobGZqajBpeHQ1OTRsSG5sQ1NYNkZrMmhvWEhKUW01S3JtOVBKSmttK0xTRjVsRTd3NUxtWTRvYTFXSGpkY0dwV1VsQlNQY000YnprOGU0bVE9PSJ9fSwiZmVkZXJhdGVkQXNzZXJ0aW9ucyI6e30sImF1ZCI6ImFwaS1ldS52b25hZ2UuY29tIiwiZXhwIjoxNzE3MDkyODY4LCJqdGkiOiJmNDZhYTViOC1hODA2LTRjMzctODQyMS02OGYwMzJjNDlhMWYiLCJpYXQiOjE3MTcwOTE5NzAsImlzcyI6IlZJQU0tSUFQIiwibmJmIjoxNzE3MDkxOTU1fQ.iLUbyDPR1HGLKh29fy6fqK65Q1O7mjWOletAEPJD4eu7gb0E85EL4M9R7ckJq5lIvgedQt3vBheTaON9_u-VYjMqo8ulPoEoGUDHbOzNbs4MmCW0_CRdDPGyxnUhvcbuJhPgnEHxmfHjJBljncUnk-Z7XCgyNajBNXeQQnHkRF_6NMngxJ-qjjhqbYL0VsF_JS7-TXxixNL0KAFl0SeN2DjkfwRBCclP-69CTExDjyOvouAcchqi-6ZYj_tXPCrTADuzUrQrW8C5nHp2-XjWJSFKzyvi48n8V1U6KseV-eYzBzvy7bJf0tRMX7G6gctTYq3DxdC_eXvXlnp1zx16mg'
)


def test_number_plus_prefixes():
assert network_auth._ensure_plus_prefix('447700900000') == '+447700900000'
assert network_auth._ensure_plus_prefix('+447700900000') == '+447700900000'


@responses.activate
def test_oidc_request_permissions_error():
build_response(
path,
'POST',
'https://api-eu.vonage.com/oauth2/bc-authorize',
'oidc_request_permissions_error.json',
status_code=400,
)

with raises(HttpRequestError) as err:
response = network_auth.make_oidc_request(
number='447700900000',
scope='dpv:FraudPreventionAndDetection#check-sim-swap',
)
assert err.match('"title": "Bad Request"')
Loading

0 comments on commit f65afe5

Please sign in to comment.