uverify 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- uverify-0.1.0/.gitignore +14 -0
- uverify-0.1.0/PKG-INFO +118 -0
- uverify-0.1.0/README.md +101 -0
- uverify-0.1.0/pyproject.toml +28 -0
- uverify-0.1.0/src/uverify/__init__.py +8 -0
- uverify-0.1.0/src/uverify/client.py +307 -0
- uverify-0.1.0/src/uverify/errors.py +33 -0
- uverify-0.1.0/src/uverify/py.typed +0 -0
- uverify-0.1.0/src/uverify/webhooks.py +44 -0
- uverify-0.1.0/tests/test_client.py +187 -0
- uverify-0.1.0/tests/test_http.py +68 -0
- uverify-0.1.0/tests/test_sandbox.py +44 -0
uverify-0.1.0/.gitignore
ADDED
uverify-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: uverify
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python library for the UVerify API: BVN, NIN and ID checks, liveness, face match, ID documents and AML screening for Nigeria.
|
|
5
|
+
Project-URL: Homepage, https://uverify.com.ng
|
|
6
|
+
Project-URL: Documentation, https://uverify.com.ng/docs
|
|
7
|
+
Project-URL: Source, https://github.com/elastodev/uverify-sdks
|
|
8
|
+
Author: Elasto Web Services Limited
|
|
9
|
+
License: MIT
|
|
10
|
+
Keywords: aml,bvn,face-match,kyc,liveness,nigeria,nin,uverify
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Python: >=3.8
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# UVerify for Python
|
|
19
|
+
|
|
20
|
+
The official Python library for [UVerify](https://uverify.com.ng): BVN, NIN, driver’s licence and voter’s card checks, liveness and face match, ID documents, AML screening and CAC, for Nigerian businesses.
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install uverify
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Python 3.8 or later. No dependencies.
|
|
27
|
+
|
|
28
|
+
## Quick start
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from uverify import UVerify
|
|
32
|
+
|
|
33
|
+
uverify = UVerify("uvk_test_...") # or set UVERIFY_API_KEY; uvk_test_ is the free sandbox
|
|
34
|
+
|
|
35
|
+
v = uverify.identity.nin(id_number="12345678901", first_name="Adaeze", last_name="Okafor")
|
|
36
|
+
if v["status"] == "verified":
|
|
37
|
+
print(v["data"], v["field_matches"])
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
In the sandbox the last two digits of the ID number choose the result: `00` not found, `99` registry error, `98` face mismatch, anything else verified.
|
|
41
|
+
|
|
42
|
+
## A live customer, end to end
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
session = uverify.liveness.create_session(redirect_url="https://yourapp.com/kyc/done")
|
|
46
|
+
# send the person to session["url"]; when they come back:
|
|
47
|
+
v = uverify.identity.bvn_face_match(
|
|
48
|
+
id_number="22212345678", first_name="Adaeze", last_name="Okafor",
|
|
49
|
+
liveness_session_id=session["id"],
|
|
50
|
+
aml_screening=True,
|
|
51
|
+
)
|
|
52
|
+
if v["status"] == "verified" and v["face_match"]["status"] == "matched" and v["aml_screening"]["status"] == "clear":
|
|
53
|
+
... # approve
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Everything else
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
uverify.identity.bvn(id_number=..., first_name=..., last_name=...) # also drivers_license, voters_card, tin
|
|
60
|
+
uverify.identity.nin_face_match(id_number=..., selfie_image=open("me.jpg", "rb").read())
|
|
61
|
+
uverify.business.cac(id_number="RC123456", aml_screening=True)
|
|
62
|
+
|
|
63
|
+
uverify.documents.verify(front_image=open("nin.jpg", "rb").read(), document_type="nin_card")
|
|
64
|
+
uverify.aml.screen(name="Adaeze Okafor", date_of_birth="1990-01-15")
|
|
65
|
+
uverify.aml.monitors.create(name="Adaeze Okafor")
|
|
66
|
+
link = uverify.kyc.create_link(customer_name="Ada", require_document=True) # send link["url"]
|
|
67
|
+
|
|
68
|
+
uverify.verifications.list(status="verified")
|
|
69
|
+
uverify.account.balance()
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Responses are plain dicts, exactly as in the [API reference](https://uverify.com.ng/docs).
|
|
73
|
+
|
|
74
|
+
## Errors
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from uverify import UVerifyError
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
uverify.identity.nin(id_number=id_number)
|
|
81
|
+
except UVerifyError as e:
|
|
82
|
+
if e.code == "insufficient_balance":
|
|
83
|
+
...
|
|
84
|
+
print(e.code, e.message, e.request_id, e.details)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
A check that finds nothing isn’t an error: it returns `status: "not_found"` (and you aren’t charged).
|
|
88
|
+
|
|
89
|
+
## Retries
|
|
90
|
+
|
|
91
|
+
Timeouts, connection errors, 5xx and rate limits are retried twice with backoff (`max_retries`, `timeout`). Every check carries a `reference` (generated if you don’t pass one), so a retry never charges twice, and if the first attempt went through you get that result.
|
|
92
|
+
|
|
93
|
+
## Webhooks
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
from uverify import construct_event, UVerifySignatureError
|
|
97
|
+
|
|
98
|
+
@app.post("/webhooks/uverify") # Flask/FastAPI/Django: use the raw body
|
|
99
|
+
def hook():
|
|
100
|
+
try:
|
|
101
|
+
event = construct_event(request.get_data(), request.headers.get("UVerify-Signature"), WEBHOOK_SECRET)
|
|
102
|
+
except UVerifySignatureError:
|
|
103
|
+
return "", 400
|
|
104
|
+
if event["type"] == "verification.completed":
|
|
105
|
+
...
|
|
106
|
+
return "", 200
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Deliveries can repeat: dedupe on `event["id"]`.
|
|
110
|
+
|
|
111
|
+
## Tests
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
python -m unittest discover -s tests # offline
|
|
115
|
+
UVERIFY_TEST_KEY=uvk_test_... python -m unittest tests.test_sandbox
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
MIT licence.
|
uverify-0.1.0/README.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# UVerify for Python
|
|
2
|
+
|
|
3
|
+
The official Python library for [UVerify](https://uverify.com.ng): BVN, NIN, driver’s licence and voter’s card checks, liveness and face match, ID documents, AML screening and CAC, for Nigerian businesses.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install uverify
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Python 3.8 or later. No dependencies.
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from uverify import UVerify
|
|
15
|
+
|
|
16
|
+
uverify = UVerify("uvk_test_...") # or set UVERIFY_API_KEY; uvk_test_ is the free sandbox
|
|
17
|
+
|
|
18
|
+
v = uverify.identity.nin(id_number="12345678901", first_name="Adaeze", last_name="Okafor")
|
|
19
|
+
if v["status"] == "verified":
|
|
20
|
+
print(v["data"], v["field_matches"])
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
In the sandbox the last two digits of the ID number choose the result: `00` not found, `99` registry error, `98` face mismatch, anything else verified.
|
|
24
|
+
|
|
25
|
+
## A live customer, end to end
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
session = uverify.liveness.create_session(redirect_url="https://yourapp.com/kyc/done")
|
|
29
|
+
# send the person to session["url"]; when they come back:
|
|
30
|
+
v = uverify.identity.bvn_face_match(
|
|
31
|
+
id_number="22212345678", first_name="Adaeze", last_name="Okafor",
|
|
32
|
+
liveness_session_id=session["id"],
|
|
33
|
+
aml_screening=True,
|
|
34
|
+
)
|
|
35
|
+
if v["status"] == "verified" and v["face_match"]["status"] == "matched" and v["aml_screening"]["status"] == "clear":
|
|
36
|
+
... # approve
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Everything else
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
uverify.identity.bvn(id_number=..., first_name=..., last_name=...) # also drivers_license, voters_card, tin
|
|
43
|
+
uverify.identity.nin_face_match(id_number=..., selfie_image=open("me.jpg", "rb").read())
|
|
44
|
+
uverify.business.cac(id_number="RC123456", aml_screening=True)
|
|
45
|
+
|
|
46
|
+
uverify.documents.verify(front_image=open("nin.jpg", "rb").read(), document_type="nin_card")
|
|
47
|
+
uverify.aml.screen(name="Adaeze Okafor", date_of_birth="1990-01-15")
|
|
48
|
+
uverify.aml.monitors.create(name="Adaeze Okafor")
|
|
49
|
+
link = uverify.kyc.create_link(customer_name="Ada", require_document=True) # send link["url"]
|
|
50
|
+
|
|
51
|
+
uverify.verifications.list(status="verified")
|
|
52
|
+
uverify.account.balance()
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Responses are plain dicts, exactly as in the [API reference](https://uverify.com.ng/docs).
|
|
56
|
+
|
|
57
|
+
## Errors
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from uverify import UVerifyError
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
uverify.identity.nin(id_number=id_number)
|
|
64
|
+
except UVerifyError as e:
|
|
65
|
+
if e.code == "insufficient_balance":
|
|
66
|
+
...
|
|
67
|
+
print(e.code, e.message, e.request_id, e.details)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
A check that finds nothing isn’t an error: it returns `status: "not_found"` (and you aren’t charged).
|
|
71
|
+
|
|
72
|
+
## Retries
|
|
73
|
+
|
|
74
|
+
Timeouts, connection errors, 5xx and rate limits are retried twice with backoff (`max_retries`, `timeout`). Every check carries a `reference` (generated if you don’t pass one), so a retry never charges twice, and if the first attempt went through you get that result.
|
|
75
|
+
|
|
76
|
+
## Webhooks
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from uverify import construct_event, UVerifySignatureError
|
|
80
|
+
|
|
81
|
+
@app.post("/webhooks/uverify") # Flask/FastAPI/Django: use the raw body
|
|
82
|
+
def hook():
|
|
83
|
+
try:
|
|
84
|
+
event = construct_event(request.get_data(), request.headers.get("UVerify-Signature"), WEBHOOK_SECRET)
|
|
85
|
+
except UVerifySignatureError:
|
|
86
|
+
return "", 400
|
|
87
|
+
if event["type"] == "verification.completed":
|
|
88
|
+
...
|
|
89
|
+
return "", 200
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Deliveries can repeat: dedupe on `event["id"]`.
|
|
93
|
+
|
|
94
|
+
## Tests
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
python -m unittest discover -s tests # offline
|
|
98
|
+
UVERIFY_TEST_KEY=uvk_test_... python -m unittest tests.test_sandbox
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
MIT licence.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.18"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "uverify"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python library for the UVerify API: BVN, NIN and ID checks, liveness, face match, ID documents and AML screening for Nigeria."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Elasto Web Services Limited" }]
|
|
13
|
+
keywords = ["uverify", "kyc", "bvn", "nin", "liveness", "face-match", "aml", "nigeria"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Typing :: Typed",
|
|
19
|
+
]
|
|
20
|
+
dependencies = []
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://uverify.com.ng"
|
|
24
|
+
Documentation = "https://uverify.com.ng/docs"
|
|
25
|
+
Source = "https://github.com/elastodev/uverify-sdks"
|
|
26
|
+
|
|
27
|
+
[tool.hatch.build.targets.wheel]
|
|
28
|
+
packages = ["src/uverify"]
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Official Python library for the UVerify API (https://uverify.com.ng/docs)."""
|
|
2
|
+
|
|
3
|
+
from .client import VERSION, UVerify
|
|
4
|
+
from .errors import UVerifyConnectionError, UVerifyError, UVerifySignatureError
|
|
5
|
+
from .webhooks import construct_event, sign_payload
|
|
6
|
+
|
|
7
|
+
__version__ = VERSION
|
|
8
|
+
__all__ = ["UVerify", "UVerifyError", "UVerifyConnectionError", "UVerifySignatureError", "construct_event", "sign_payload", "__version__"]
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import random
|
|
8
|
+
import secrets
|
|
9
|
+
import time
|
|
10
|
+
import urllib.error
|
|
11
|
+
import urllib.parse
|
|
12
|
+
import urllib.request
|
|
13
|
+
from typing import Any, Callable, Dict, Optional, Tuple, Union
|
|
14
|
+
|
|
15
|
+
from . import webhooks as _webhooks
|
|
16
|
+
from .errors import UVerifyConnectionError, UVerifyError
|
|
17
|
+
|
|
18
|
+
VERSION = "0.1.0"
|
|
19
|
+
DEFAULT_BASE_URL = "https://api.uverify.com.ng/v1"
|
|
20
|
+
IMAGE_FIELDS = ("selfie_image", "front_image", "back_image")
|
|
21
|
+
|
|
22
|
+
#: (method, url, headers, body, timeout seconds) -> (status, headers, body bytes)
|
|
23
|
+
Transport = Callable[[str, str, Dict[str, str], Optional[bytes], float], Tuple[int, Dict[str, str], bytes]]
|
|
24
|
+
|
|
25
|
+
ImageInput = Union[bytes, bytearray, str]
|
|
26
|
+
Json = Dict[str, Any]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _urllib_transport(method: str, url: str, headers: Dict[str, str], body: Optional[bytes], timeout: float) -> Tuple[int, Dict[str, str], bytes]:
|
|
30
|
+
req = urllib.request.Request(url, data=body, method=method, headers=headers)
|
|
31
|
+
try:
|
|
32
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
33
|
+
return r.status, {k.lower(): v for k, v in r.headers.items()}, r.read()
|
|
34
|
+
except urllib.error.HTTPError as e: # 4xx/5xx still carry a JSON body
|
|
35
|
+
with e:
|
|
36
|
+
return e.code, {k.lower(): v for k, v in (e.headers or {}).items()}, e.read()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _new_reference(prefix: str) -> str:
|
|
40
|
+
return f"{prefix}_{secrets.token_urlsafe(9)}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _b64(v: ImageInput) -> str:
|
|
44
|
+
return v if isinstance(v, str) else base64.b64encode(bytes(v)).decode()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _backoff(attempt: int) -> float:
|
|
48
|
+
return min(8.0, 0.5 * 2**attempt) * (0.75 + random.random() * 0.5)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class _DuplicateOnRetry(Exception):
|
|
52
|
+
def __init__(self, err: UVerifyError) -> None:
|
|
53
|
+
self.err = err
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class UVerify:
|
|
57
|
+
"""The UVerify API client.
|
|
58
|
+
|
|
59
|
+
>>> from uverify import UVerify
|
|
60
|
+
>>> uverify = UVerify(api_key="uvk_test_...")
|
|
61
|
+
>>> v = uverify.identity.nin(id_number="12345678901")
|
|
62
|
+
>>> v["status"]
|
|
63
|
+
'verified'
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
webhooks = _webhooks
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
api_key: Optional[str] = None,
|
|
71
|
+
*,
|
|
72
|
+
base_url: Optional[str] = None,
|
|
73
|
+
timeout: float = 60.0,
|
|
74
|
+
max_retries: int = 2,
|
|
75
|
+
transport: Optional[Transport] = None,
|
|
76
|
+
) -> None:
|
|
77
|
+
key = api_key or os.environ.get("UVERIFY_API_KEY", "")
|
|
78
|
+
if not key:
|
|
79
|
+
raise ValueError("UVerify: pass api_key (uvk_test_... or uvk_live_...) or set UVERIFY_API_KEY.")
|
|
80
|
+
self._key = key
|
|
81
|
+
self._base = (base_url or os.environ.get("UVERIFY_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
82
|
+
self._timeout = timeout
|
|
83
|
+
self._max_retries = max_retries
|
|
84
|
+
self._transport = transport or _urllib_transport
|
|
85
|
+
self.identity = _Identity(self)
|
|
86
|
+
self.business = _Business(self)
|
|
87
|
+
self.verifications = _Verifications(self)
|
|
88
|
+
self.liveness = _Liveness(self)
|
|
89
|
+
self.documents = _Documents(self)
|
|
90
|
+
self.aml = _Aml(self)
|
|
91
|
+
self.kyc = _Kyc(self)
|
|
92
|
+
self.account = _Account(self)
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def environment(self) -> str:
|
|
96
|
+
"""``"test"`` for a sandbox key, ``"live"`` for a live one."""
|
|
97
|
+
return "live" if "_live_" in self._key else "test"
|
|
98
|
+
|
|
99
|
+
# ── transport ──────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
def _request(self, method: str, path: str, *, query: Optional[Json] = None, body: Optional[Json] = None, retryable: Optional[bool] = None) -> Any:
|
|
102
|
+
url = self._base + path
|
|
103
|
+
q = {k: ("true" if v is True else "false" if v is False else v) for k, v in (query or {}).items() if v is not None}
|
|
104
|
+
if q:
|
|
105
|
+
url += "?" + urllib.parse.urlencode(q)
|
|
106
|
+
data = json.dumps({k: v for k, v in body.items() if v is not None}).encode() if body is not None else None
|
|
107
|
+
headers = {
|
|
108
|
+
"authorization": f"Bearer {self._key}",
|
|
109
|
+
"accept": "application/json",
|
|
110
|
+
"user-agent": f"uverify-python/{VERSION} python/{platform.python_version()}",
|
|
111
|
+
}
|
|
112
|
+
if data is not None:
|
|
113
|
+
headers["content-type"] = "application/json"
|
|
114
|
+
can_retry = (method == "GET") if retryable is None else retryable
|
|
115
|
+
|
|
116
|
+
attempt = 0
|
|
117
|
+
while True:
|
|
118
|
+
try:
|
|
119
|
+
status, rh, raw = self._transport(method, url, headers, data, self._timeout)
|
|
120
|
+
except Exception as e: # timeout, DNS, reset
|
|
121
|
+
if can_retry and attempt < self._max_retries:
|
|
122
|
+
time.sleep(_backoff(attempt))
|
|
123
|
+
attempt += 1
|
|
124
|
+
continue
|
|
125
|
+
raise UVerifyConnectionError(f"Couldn't reach UVerify: {e}") from e
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
payload = json.loads(raw.decode() or "null")
|
|
129
|
+
except ValueError:
|
|
130
|
+
payload = None
|
|
131
|
+
if 200 <= status < 300 and not (isinstance(payload, dict) and payload.get("success") is False):
|
|
132
|
+
return payload.get("data", payload) if isinstance(payload, dict) else payload
|
|
133
|
+
|
|
134
|
+
e_obj = (payload or {}).get("error") or {} if isinstance(payload, dict) else {}
|
|
135
|
+
err = UVerifyError(
|
|
136
|
+
e_obj.get("message") or f"UVerify returned HTTP {status}.",
|
|
137
|
+
code=e_obj.get("code") or ("internal_error" if status >= 500 else "http_error"),
|
|
138
|
+
status=status,
|
|
139
|
+
request_id=(payload or {}).get("request_id") if isinstance(payload, dict) else rh.get("x-request-id"),
|
|
140
|
+
details=e_obj.get("details"),
|
|
141
|
+
)
|
|
142
|
+
rate_limited = status == 429 and err.code == "rate_limited"
|
|
143
|
+
if can_retry and attempt < self._max_retries and (rate_limited or (status >= 500 and status != 501)):
|
|
144
|
+
after = (err.details or {}).get("retry_after_seconds") if isinstance(err.details, dict) else None
|
|
145
|
+
time.sleep(min(float(after), 60.0) if rate_limited and after else _backoff(attempt))
|
|
146
|
+
attempt += 1
|
|
147
|
+
continue
|
|
148
|
+
if attempt > 0 and err.code == "duplicate_reference":
|
|
149
|
+
raise _DuplicateOnRetry(err)
|
|
150
|
+
raise err
|
|
151
|
+
|
|
152
|
+
def _prepare(self, params: Json, prefix: str) -> Json:
|
|
153
|
+
body = dict(params)
|
|
154
|
+
body.setdefault("reference", None)
|
|
155
|
+
if not body["reference"]:
|
|
156
|
+
body["reference"] = _new_reference(prefix)
|
|
157
|
+
for f in IMAGE_FIELDS:
|
|
158
|
+
if body.get(f) is not None:
|
|
159
|
+
body[f] = _b64(body[f])
|
|
160
|
+
return body
|
|
161
|
+
|
|
162
|
+
def _check(self, path: str, params: Json) -> Json:
|
|
163
|
+
"""An identity/business check: always carries a reference, so a retry never charges twice."""
|
|
164
|
+
body = self._prepare(params, "sdk")
|
|
165
|
+
try:
|
|
166
|
+
return self._request("POST", path, body=body, retryable=True)
|
|
167
|
+
except _DuplicateOnRetry as d:
|
|
168
|
+
# The first attempt went through but its answer was lost: return that result.
|
|
169
|
+
found = self.verifications.list(reference=body["reference"], per_page=1)
|
|
170
|
+
if found["items"]:
|
|
171
|
+
return self.verifications.get(found["items"][0]["id"])
|
|
172
|
+
raise d.err from None
|
|
173
|
+
|
|
174
|
+
def _create(self, path: str, params: Json, prefix: str) -> Json:
|
|
175
|
+
try:
|
|
176
|
+
return self._request("POST", path, body=self._prepare(params, prefix), retryable=True)
|
|
177
|
+
except _DuplicateOnRetry as d:
|
|
178
|
+
raise d.err from None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _q(v: str) -> str:
|
|
182
|
+
return urllib.parse.quote(v, safe="")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class _Resource:
|
|
186
|
+
def __init__(self, client: UVerify) -> None:
|
|
187
|
+
self._c = client
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class _Identity(_Resource):
|
|
191
|
+
def bvn(self, *, id_number: str, first_name: str, last_name: str, **kw: Any) -> Json:
|
|
192
|
+
"""BVN lookup. Names are required by the registry. Also: dob, include_photo, reference, aml_screening, aml_monitoring."""
|
|
193
|
+
return self._c._check("/identity/bvn", {"id_number": id_number, "first_name": first_name, "last_name": last_name, **kw})
|
|
194
|
+
|
|
195
|
+
def bvn_face_match(self, *, id_number: str, first_name: str, last_name: str, liveness_session_id: Optional[str] = None, selfie_image: Optional[ImageInput] = None, **kw: Any) -> Json:
|
|
196
|
+
"""BVN lookup + face match. Send liveness_session_id (recommended) or selfie_image (bytes or base64)."""
|
|
197
|
+
return self._c._check("/identity/bvn/face-match", {"id_number": id_number, "first_name": first_name, "last_name": last_name, "liveness_session_id": liveness_session_id, "selfie_image": selfie_image, **kw})
|
|
198
|
+
|
|
199
|
+
def nin(self, *, id_number: str, **kw: Any) -> Json:
|
|
200
|
+
return self._c._check("/identity/nin", {"id_number": id_number, **kw})
|
|
201
|
+
|
|
202
|
+
def nin_face_match(self, *, id_number: str, liveness_session_id: Optional[str] = None, selfie_image: Optional[ImageInput] = None, **kw: Any) -> Json:
|
|
203
|
+
return self._c._check("/identity/nin/face-match", {"id_number": id_number, "liveness_session_id": liveness_session_id, "selfie_image": selfie_image, **kw})
|
|
204
|
+
|
|
205
|
+
def drivers_license(self, *, id_number: str, first_name: str, last_name: str, **kw: Any) -> Json:
|
|
206
|
+
return self._c._check("/identity/drivers-license", {"id_number": id_number, "first_name": first_name, "last_name": last_name, **kw})
|
|
207
|
+
|
|
208
|
+
def drivers_license_face_match(self, *, id_number: str, first_name: str, last_name: str, liveness_session_id: Optional[str] = None, selfie_image: Optional[ImageInput] = None, **kw: Any) -> Json:
|
|
209
|
+
return self._c._check("/identity/drivers-license/face-match", {"id_number": id_number, "first_name": first_name, "last_name": last_name, "liveness_session_id": liveness_session_id, "selfie_image": selfie_image, **kw})
|
|
210
|
+
|
|
211
|
+
def voters_card(self, *, id_number: str, first_name: str, last_name: str, **kw: Any) -> Json:
|
|
212
|
+
return self._c._check("/identity/voters-card", {"id_number": id_number, "first_name": first_name, "last_name": last_name, **kw})
|
|
213
|
+
|
|
214
|
+
def voters_card_face_match(self, *, id_number: str, first_name: str, last_name: str, liveness_session_id: Optional[str] = None, selfie_image: Optional[ImageInput] = None, **kw: Any) -> Json:
|
|
215
|
+
return self._c._check("/identity/voters-card/face-match", {"id_number": id_number, "first_name": first_name, "last_name": last_name, "liveness_session_id": liveness_session_id, "selfie_image": selfie_image, **kw})
|
|
216
|
+
|
|
217
|
+
def tin(self, *, id_number: str, reference: Optional[str] = None) -> Json:
|
|
218
|
+
return self._c._check("/identity/tin", {"id_number": id_number, "reference": reference})
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class _Business(_Resource):
|
|
222
|
+
def cac(self, *, id_number: str, **kw: Any) -> Json:
|
|
223
|
+
"""CAC lookup (RC/BN/IT number). aml_screening=True screens the company and each director."""
|
|
224
|
+
return self._c._check("/business/cac", {"id_number": id_number, **kw})
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class _Verifications(_Resource):
|
|
228
|
+
def list(self, **params: Any) -> Json:
|
|
229
|
+
"""Filters: type, service, status, reference, page, per_page."""
|
|
230
|
+
return self._c._request("GET", "/verifications", query=params)
|
|
231
|
+
|
|
232
|
+
def get(self, id: str) -> Json:
|
|
233
|
+
"""One verification, with its record."""
|
|
234
|
+
return self._c._request("GET", f"/verifications/{_q(id)}")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class _Liveness(_Resource):
|
|
238
|
+
def create_session(self, **params: Any) -> Json:
|
|
239
|
+
"""A hosted camera check: send the person to session["url"], then pass session["id"] to a face match."""
|
|
240
|
+
return self._c._create("/liveness/sessions", params, "lv")
|
|
241
|
+
|
|
242
|
+
def get_session(self, id: str) -> Json:
|
|
243
|
+
return self._c._request("GET", f"/liveness/sessions/{_q(id)}")
|
|
244
|
+
|
|
245
|
+
def simulate(self, id: str, *, outcome: str, face: Optional[str] = None) -> Json:
|
|
246
|
+
"""Sandbox only: finish a session without a camera (passed, failed or expired)."""
|
|
247
|
+
return self._c._request("POST", f"/liveness/sessions/{_q(id)}/simulate", body={"outcome": outcome, "face": face})
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class _Documents(_Resource):
|
|
251
|
+
def verify(self, *, front_image: ImageInput, **params: Any) -> Json:
|
|
252
|
+
"""Read and check a photo of an ID. Images: bytes or base64. Also: back_image, document_type, liveness_session_id, selfie_image, verify_with_registry, reference."""
|
|
253
|
+
return self._c._create("/documents/verify", {"front_image": front_image, **params}, "doc")
|
|
254
|
+
|
|
255
|
+
def get(self, id: str) -> Json:
|
|
256
|
+
return self._c._request("GET", f"/documents/{_q(id)}")
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class _Monitors(_Resource):
|
|
260
|
+
def create(self, *, name: str, **params: Any) -> Json:
|
|
261
|
+
"""Screen a name now and keep watching it (billed monthly)."""
|
|
262
|
+
return self._c._create("/aml/monitors", {"name": name, **params}, "amon")
|
|
263
|
+
|
|
264
|
+
def list(self, **params: Any) -> Json:
|
|
265
|
+
return self._c._request("GET", "/aml/monitors", query=params)
|
|
266
|
+
|
|
267
|
+
def get(self, id: str) -> Json:
|
|
268
|
+
return self._c._request("GET", f"/aml/monitors/{_q(id)}")
|
|
269
|
+
|
|
270
|
+
def stop(self, id: str) -> Json:
|
|
271
|
+
return self._c._request("DELETE", f"/aml/monitors/{_q(id)}", retryable=True)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
class _Aml(_Resource):
|
|
275
|
+
def __init__(self, client: UVerify) -> None:
|
|
276
|
+
super().__init__(client)
|
|
277
|
+
self.monitors = _Monitors(client)
|
|
278
|
+
|
|
279
|
+
def screen(self, *, name: str, **params: Any) -> Json:
|
|
280
|
+
"""Screen a person or organisation (entity_type="entity") against the UN, OFAC, UK, EU and Nigeria lists."""
|
|
281
|
+
return self._c._create("/aml/screen", {"name": name, **params}, "aml")
|
|
282
|
+
|
|
283
|
+
def get_screening(self, id: str) -> Json:
|
|
284
|
+
return self._c._request("GET", f"/aml/screenings/{_q(id)}")
|
|
285
|
+
|
|
286
|
+
def lists(self) -> Any:
|
|
287
|
+
return self._c._request("GET", "/aml/lists")
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
class _Kyc(_Resource):
|
|
291
|
+
def create_link(self, **params: Any) -> Json:
|
|
292
|
+
"""A hosted verification link: send link["url"] to your customer."""
|
|
293
|
+
return self._c._create("/kyc/requests", params, "kyc")
|
|
294
|
+
|
|
295
|
+
def get_link(self, id: str) -> Json:
|
|
296
|
+
return self._c._request("GET", f"/kyc/requests/{_q(id)}")
|
|
297
|
+
|
|
298
|
+
def list_links(self, **params: Any) -> Json:
|
|
299
|
+
return self._c._request("GET", "/kyc/requests", query=params)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class _Account(_Resource):
|
|
303
|
+
def balance(self) -> Json:
|
|
304
|
+
return self._c._request("GET", "/balance")
|
|
305
|
+
|
|
306
|
+
def pricing(self) -> Any:
|
|
307
|
+
return self._c._request("GET", "/pricing")
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class UVerifyError(Exception):
|
|
7
|
+
"""Every API error. Branch on ``code``: it's stable (https://uverify.com.ng/docs#responses)."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, message: str, *, code: str, status: int, request_id: Optional[str] = None, details: Any = None) -> None:
|
|
10
|
+
super().__init__(message)
|
|
11
|
+
self.message = message
|
|
12
|
+
#: e.g. validation_error, insufficient_balance, duplicate_reference, rate_limited
|
|
13
|
+
self.code = code
|
|
14
|
+
#: HTTP status (0 when no response arrived)
|
|
15
|
+
self.status = status
|
|
16
|
+
#: Quote this to UVerify support to trace the request
|
|
17
|
+
self.request_id = request_id
|
|
18
|
+
#: Field errors for validation_error; retry_after_seconds for rate_limited; ...
|
|
19
|
+
self.details = details
|
|
20
|
+
|
|
21
|
+
def __repr__(self) -> str:
|
|
22
|
+
return f"UVerifyError(code={self.code!r}, status={self.status}, message={self.message!r}, request_id={self.request_id!r})"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class UVerifyConnectionError(UVerifyError):
|
|
26
|
+
"""The request didn't complete (timeout, DNS, connection reset). Safe to retry with the same reference."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, message: str) -> None:
|
|
29
|
+
super().__init__(message, code="connection_error", status=0)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class UVerifySignatureError(Exception):
|
|
33
|
+
"""A webhook failed signature or timestamp verification. Don't trust its body."""
|
|
File without changes
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any, Dict, Optional, Union
|
|
8
|
+
|
|
9
|
+
from .errors import UVerifySignatureError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def construct_event(raw_body: Union[bytes, str], signature_header: Optional[str], secret: str, tolerance_seconds: int = 300) -> Dict[str, Any]:
|
|
13
|
+
"""Verify a webhook and return its event (a dict: id, type, created_at, environment, data).
|
|
14
|
+
|
|
15
|
+
Pass the raw request body exactly as received, the ``UVerify-Signature``
|
|
16
|
+
header and your endpoint's signing secret (``whsec_...``). Raises
|
|
17
|
+
:class:`UVerifySignatureError` if the signature doesn't match or the event
|
|
18
|
+
is older than ``tolerance_seconds`` (a replay). Deliveries can repeat:
|
|
19
|
+
dedupe on ``event["id"]``.
|
|
20
|
+
"""
|
|
21
|
+
if not signature_header:
|
|
22
|
+
raise UVerifySignatureError("Missing UVerify-Signature header.")
|
|
23
|
+
if not secret:
|
|
24
|
+
raise UVerifySignatureError("Missing webhook secret.")
|
|
25
|
+
parts = dict(p.strip().split("=", 1) for p in signature_header.split(",") if "=" in p)
|
|
26
|
+
t, v1 = parts.get("t"), parts.get("v1")
|
|
27
|
+
if not t or not v1 or not t.isdigit():
|
|
28
|
+
raise UVerifySignatureError("Malformed UVerify-Signature header.")
|
|
29
|
+
body = raw_body.decode("utf-8") if isinstance(raw_body, (bytes, bytearray)) else raw_body
|
|
30
|
+
expected = hmac.new(secret.encode(), f"{t}.{body}".encode(), hashlib.sha256).hexdigest()
|
|
31
|
+
if not hmac.compare_digest(expected, v1):
|
|
32
|
+
raise UVerifySignatureError("Signature does not match. Check the secret and that you passed the raw body.")
|
|
33
|
+
if tolerance_seconds > 0 and abs(time.time() - int(t)) > tolerance_seconds:
|
|
34
|
+
raise UVerifySignatureError("Signature is too old (possible replay).")
|
|
35
|
+
try:
|
|
36
|
+
return json.loads(body)
|
|
37
|
+
except ValueError as e:
|
|
38
|
+
raise UVerifySignatureError("Body is not JSON.") from e
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def sign_payload(body: str, secret: str, timestamp: Optional[int] = None) -> str:
|
|
42
|
+
"""Sign a payload the way UVerify does, for your own tests."""
|
|
43
|
+
t = int(time.time()) if timestamp is None else timestamp
|
|
44
|
+
return f"t={t},v1={hmac.new(secret.encode(), f'{t}.{body}'.encode(), hashlib.sha256).hexdigest()}"
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import time
|
|
6
|
+
import unittest
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
|
10
|
+
|
|
11
|
+
from uverify import UVerify, UVerifyConnectionError, UVerifyError, UVerifySignatureError, construct_event, sign_payload # noqa: E402
|
|
12
|
+
from uverify import client as client_mod # noqa: E402
|
|
13
|
+
|
|
14
|
+
client_mod._backoff = lambda attempt: 0.0 # no real waiting in tests
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def ok(data, status=200):
|
|
18
|
+
return status, {"content-type": "application/json"}, json.dumps({"success": True, "data": data, "request_id": "req_1"}).encode()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def fail(status, code, message="nope", details=None):
|
|
22
|
+
return status, {}, json.dumps({"success": False, "error": {"code": code, "message": message, "details": details}, "request_id": "req_err"}).encode()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Fake:
|
|
26
|
+
"""A transport that answers from handlers in order, recording each call."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, *handlers):
|
|
29
|
+
self.handlers = handlers
|
|
30
|
+
self.calls = []
|
|
31
|
+
|
|
32
|
+
def __call__(self, method, url, headers, body, timeout):
|
|
33
|
+
call = {"method": method, "url": url, "headers": headers, "body": json.loads(body) if body else None}
|
|
34
|
+
self.calls.append(call)
|
|
35
|
+
h = self.handlers[min(len(self.calls) - 1, len(self.handlers) - 1)]
|
|
36
|
+
r = h(call)
|
|
37
|
+
if r == "network-error":
|
|
38
|
+
raise OSError("connection reset")
|
|
39
|
+
return r
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def client(t, **kw):
|
|
43
|
+
return UVerify("uvk_test_abc", base_url="https://api.test/v1", transport=t, **kw)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Requests(unittest.TestCase):
|
|
47
|
+
def test_auth_reference_and_unwrap(self):
|
|
48
|
+
t = Fake(lambda c: ok({"id": "v1", "status": "verified"}))
|
|
49
|
+
v = client(t).identity.nin(id_number="12345678901")
|
|
50
|
+
self.assertEqual(v, {"id": "v1", "status": "verified"})
|
|
51
|
+
self.assertEqual(t.calls[0]["url"], "https://api.test/v1/identity/nin")
|
|
52
|
+
self.assertEqual(t.calls[0]["headers"]["authorization"], "Bearer uvk_test_abc")
|
|
53
|
+
self.assertTrue(t.calls[0]["headers"]["user-agent"].startswith("uverify-python/"))
|
|
54
|
+
self.assertTrue(t.calls[0]["body"]["reference"].startswith("sdk_"))
|
|
55
|
+
|
|
56
|
+
def test_own_reference_and_no_nones(self):
|
|
57
|
+
t = Fake(lambda c: ok({}))
|
|
58
|
+
client(t).identity.bvn(id_number="22212345678", first_name="Ada", last_name="Okafor", reference="mine-1", dob=None)
|
|
59
|
+
self.assertEqual(t.calls[0]["body"], {"id_number": "22212345678", "first_name": "Ada", "last_name": "Okafor", "reference": "mine-1"})
|
|
60
|
+
|
|
61
|
+
def test_bytes_images_become_base64(self):
|
|
62
|
+
t = Fake(lambda c: ok({}))
|
|
63
|
+
client(t).identity.nin_face_match(id_number="1", selfie_image=b"jpeg-bytes")
|
|
64
|
+
client(t).documents.verify(front_image=bytes([1, 2, 3]), back_image="already-base64")
|
|
65
|
+
self.assertEqual(t.calls[0]["body"]["selfie_image"], base64.b64encode(b"jpeg-bytes").decode())
|
|
66
|
+
self.assertEqual(t.calls[1]["body"]["front_image"], "AQID")
|
|
67
|
+
self.assertEqual(t.calls[1]["body"]["back_image"], "already-base64")
|
|
68
|
+
|
|
69
|
+
def test_list_query(self):
|
|
70
|
+
t = Fake(lambda c: ok({"items": [], "pagination": {}}))
|
|
71
|
+
client(t).verifications.list(status="verified", page=2)
|
|
72
|
+
self.assertEqual(t.calls[0]["url"], "https://api.test/v1/verifications?status=verified&page=2")
|
|
73
|
+
|
|
74
|
+
def test_environment_and_key(self):
|
|
75
|
+
self.assertEqual(UVerify("uvk_live_x").environment, "live")
|
|
76
|
+
self.assertEqual(UVerify("uvk_test_x").environment, "test")
|
|
77
|
+
was = os.environ.pop("UVERIFY_API_KEY", None)
|
|
78
|
+
with self.assertRaises(ValueError):
|
|
79
|
+
UVerify()
|
|
80
|
+
if was:
|
|
81
|
+
os.environ["UVERIFY_API_KEY"] = was
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Errors(unittest.TestCase):
|
|
85
|
+
def test_error_fields(self):
|
|
86
|
+
t = Fake(lambda c: fail(400, "validation_error", "One or more fields are invalid.", ["id_number must be the 11-digit NIN"]))
|
|
87
|
+
with self.assertRaises(UVerifyError) as cm:
|
|
88
|
+
client(t).identity.nin(id_number="x")
|
|
89
|
+
e = cm.exception
|
|
90
|
+
self.assertEqual((e.code, e.status, e.request_id, e.details), ("validation_error", 400, "req_err", ["id_number must be the 11-digit NIN"]))
|
|
91
|
+
|
|
92
|
+
def test_4xx_not_retried(self):
|
|
93
|
+
t = Fake(lambda c: fail(402, "insufficient_balance"))
|
|
94
|
+
with self.assertRaises(UVerifyError):
|
|
95
|
+
client(t).identity.nin(id_number="1")
|
|
96
|
+
self.assertEqual(len(t.calls), 1)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class Retries(unittest.TestCase):
|
|
100
|
+
def test_5xx_then_ok_same_reference(self):
|
|
101
|
+
t = Fake(lambda c: fail(503, "internal_error"), lambda c: ok({"id": "v1"}))
|
|
102
|
+
self.assertEqual(client(t).identity.nin(id_number="1"), {"id": "v1"})
|
|
103
|
+
self.assertEqual(t.calls[0]["body"]["reference"], t.calls[1]["body"]["reference"])
|
|
104
|
+
|
|
105
|
+
def test_rate_limit(self):
|
|
106
|
+
t = Fake(lambda c: fail(429, "rate_limited", "slow", {"retry_after_seconds": 0}), lambda c: ok({"balance": 5}))
|
|
107
|
+
self.assertEqual(client(t).account.balance(), {"balance": 5})
|
|
108
|
+
self.assertEqual(len(t.calls), 2)
|
|
109
|
+
|
|
110
|
+
def test_recovers_lost_answer(self):
|
|
111
|
+
t = Fake(
|
|
112
|
+
lambda c: "network-error",
|
|
113
|
+
lambda c: fail(409, "duplicate_reference"),
|
|
114
|
+
lambda c: ok({"items": [{"id": "v9"}], "pagination": {}}) if "/verifications?" in c["url"] else fail(500, "x"),
|
|
115
|
+
lambda c: ok({"id": "v9", "data": {"first_name": "ADA"}}) if c["url"].endswith("/verifications/v9") else fail(500, "x"),
|
|
116
|
+
)
|
|
117
|
+
v = client(t).identity.nin(id_number="1")
|
|
118
|
+
self.assertEqual(v["id"], "v9")
|
|
119
|
+
self.assertIn(f"reference={t.calls[0]['body']['reference']}", t.calls[2]["url"])
|
|
120
|
+
|
|
121
|
+
def test_gives_up(self):
|
|
122
|
+
t = Fake(lambda c: "network-error")
|
|
123
|
+
with self.assertRaises(UVerifyConnectionError):
|
|
124
|
+
client(t, max_retries=1).identity.nin(id_number="1")
|
|
125
|
+
self.assertEqual(len(t.calls), 2)
|
|
126
|
+
|
|
127
|
+
def test_simulate_not_retried(self):
|
|
128
|
+
t = Fake(lambda c: fail(500, "internal_error"))
|
|
129
|
+
with self.assertRaises(UVerifyError):
|
|
130
|
+
client(t).liveness.simulate("s1", outcome="passed")
|
|
131
|
+
self.assertEqual(len(t.calls), 1)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class Webhooks(unittest.TestCase):
|
|
135
|
+
secret = "whsec_test123"
|
|
136
|
+
body = json.dumps({"id": "evt_1", "type": "verification.completed", "created_at": "2026-09-30T10:00:00Z", "environment": "live", "data": {"id": "v1"}})
|
|
137
|
+
|
|
138
|
+
def test_valid(self):
|
|
139
|
+
e = construct_event(self.body, sign_payload(self.body, self.secret), self.secret)
|
|
140
|
+
self.assertEqual(e["id"], "evt_1")
|
|
141
|
+
self.assertEqual(UVerify.webhooks.construct_event(self.body.encode(), sign_payload(self.body, self.secret), self.secret)["type"], "verification.completed")
|
|
142
|
+
|
|
143
|
+
def test_rejects(self):
|
|
144
|
+
sig = sign_payload(self.body, self.secret)
|
|
145
|
+
for args in [(self.body.replace("v1", "v2"), sig, self.secret), (self.body, sig, "whsec_other"), (self.body, None, self.secret), (self.body, "v1=abc", self.secret)]:
|
|
146
|
+
with self.assertRaises(UVerifySignatureError):
|
|
147
|
+
construct_event(*args)
|
|
148
|
+
old = sign_payload(self.body, self.secret, int(time.time()) - 3600)
|
|
149
|
+
with self.assertRaises(UVerifySignatureError):
|
|
150
|
+
construct_event(self.body, old, self.secret)
|
|
151
|
+
self.assertEqual(construct_event(self.body, old, self.secret, tolerance_seconds=0)["id"], "evt_1")
|
|
152
|
+
|
|
153
|
+
def test_matches_node_and_server(self):
|
|
154
|
+
# Same inputs as the Node SDK's test: identical signatures across languages.
|
|
155
|
+
import hashlib
|
|
156
|
+
import hmac
|
|
157
|
+
t = 1790590000
|
|
158
|
+
expected = hmac.new(self.secret.encode(), f"{t}.{self.body}".encode(), hashlib.sha256).hexdigest()
|
|
159
|
+
self.assertEqual(sign_payload(self.body, self.secret, t), f"t={t},v1={expected}")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class Contract(unittest.TestCase):
|
|
163
|
+
def test_covers_every_public_route(self):
|
|
164
|
+
routes = json.loads((Path(__file__).resolve().parents[2] / "contract" / "public-api.json").read_text())["routes"]
|
|
165
|
+
seen = set()
|
|
166
|
+
|
|
167
|
+
def transport(method, url, headers, body, timeout):
|
|
168
|
+
path = url.split("/v1", 1)[1].split("?")[0].replace("/id-x", "/{id}")
|
|
169
|
+
seen.add(f"{method} {path}")
|
|
170
|
+
return ok({"items": [], "pagination": {}})
|
|
171
|
+
|
|
172
|
+
u = client(transport)
|
|
173
|
+
p = dict(id_number="1", first_name="A", last_name="B")
|
|
174
|
+
u.identity.bvn(**p); u.identity.bvn_face_match(**p, liveness_session_id="s"); u.identity.nin(id_number="1"); u.identity.nin_face_match(id_number="1", liveness_session_id="s")
|
|
175
|
+
u.identity.drivers_license(**p); u.identity.drivers_license_face_match(**p, liveness_session_id="s"); u.identity.voters_card(**p); u.identity.voters_card_face_match(**p, liveness_session_id="s")
|
|
176
|
+
u.identity.tin(id_number="1"); u.business.cac(id_number="RC1")
|
|
177
|
+
u.verifications.list(); u.verifications.get("id-x"); u.account.balance(); u.account.pricing()
|
|
178
|
+
u.liveness.create_session(); u.liveness.get_session("id-x"); u.liveness.simulate("id-x", outcome="passed")
|
|
179
|
+
u.documents.verify(front_image="x"); u.documents.get("id-x")
|
|
180
|
+
u.aml.screen(name="A B"); u.aml.get_screening("id-x"); u.aml.lists()
|
|
181
|
+
u.aml.monitors.create(name="A B"); u.aml.monitors.list(); u.aml.monitors.get("id-x"); u.aml.monitors.stop("id-x")
|
|
182
|
+
u.kyc.create_link(); u.kyc.list_links(); u.kyc.get_link("id-x")
|
|
183
|
+
self.assertEqual([r for r in routes if r not in seen], [])
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
if __name__ == "__main__":
|
|
187
|
+
unittest.main()
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""The real urllib transport, against a local HTTP server (no network)."""
|
|
2
|
+
import json
|
|
3
|
+
import sys
|
|
4
|
+
import threading
|
|
5
|
+
import unittest
|
|
6
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
|
10
|
+
|
|
11
|
+
from uverify import UVerify, UVerifyError # noqa: E402
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Handler(BaseHTTPRequestHandler):
|
|
15
|
+
def log_message(self, *a): # quiet
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
def _send(self, status, obj):
|
|
19
|
+
body = json.dumps(obj).encode()
|
|
20
|
+
self.send_response(status)
|
|
21
|
+
self.send_header("content-type", "application/json")
|
|
22
|
+
self.send_header("content-length", str(len(body)))
|
|
23
|
+
self.end_headers()
|
|
24
|
+
self.wfile.write(body)
|
|
25
|
+
|
|
26
|
+
def do_GET(self):
|
|
27
|
+
if self.headers.get("authorization") != "Bearer uvk_test_local":
|
|
28
|
+
return self._send(401, {"success": False, "error": {"code": "invalid_api_key", "message": "Bad key"}, "request_id": "req_401"})
|
|
29
|
+
self._send(200, {"success": True, "data": {"balance": 1234.5, "currency": "NGN"}, "request_id": "req_ok"})
|
|
30
|
+
|
|
31
|
+
def do_POST(self):
|
|
32
|
+
n = int(self.headers.get("content-length") or 0)
|
|
33
|
+
body = json.loads(self.rfile.read(n) or b"{}")
|
|
34
|
+
if body.get("id_number") == "bad":
|
|
35
|
+
return self._send(400, {"success": False, "error": {"code": "validation_error", "message": "Invalid", "details": ["id_number must be the 11-digit NIN"]}, "request_id": "req_400"})
|
|
36
|
+
self._send(200, {"success": True, "data": {"id": "v1", "status": "verified", "echo": body}, "request_id": "req_ok"})
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class RealHttp(unittest.TestCase):
|
|
40
|
+
@classmethod
|
|
41
|
+
def setUpClass(cls):
|
|
42
|
+
cls.server = HTTPServer(("127.0.0.1", 0), Handler)
|
|
43
|
+
threading.Thread(target=cls.server.serve_forever, daemon=True).start()
|
|
44
|
+
cls.base = f"http://127.0.0.1:{cls.server.server_port}/v1"
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def tearDownClass(cls):
|
|
48
|
+
cls.server.shutdown()
|
|
49
|
+
cls.server.server_close()
|
|
50
|
+
|
|
51
|
+
def test_get_and_post(self):
|
|
52
|
+
u = UVerify("uvk_test_local", base_url=self.base)
|
|
53
|
+
self.assertEqual(u.account.balance()["balance"], 1234.5)
|
|
54
|
+
v = u.identity.nin(id_number="12345678901")
|
|
55
|
+
self.assertEqual(v["status"], "verified")
|
|
56
|
+
self.assertTrue(v["echo"]["reference"].startswith("sdk_"))
|
|
57
|
+
|
|
58
|
+
def test_error_bodies_are_read(self):
|
|
59
|
+
with self.assertRaises(UVerifyError) as cm:
|
|
60
|
+
UVerify("uvk_test_local", base_url=self.base).identity.nin(id_number="bad")
|
|
61
|
+
self.assertEqual((cm.exception.code, cm.exception.status, cm.exception.request_id), ("validation_error", 400, "req_400"))
|
|
62
|
+
with self.assertRaises(UVerifyError) as cm:
|
|
63
|
+
UVerify("uvk_test_wrong", base_url=self.base).account.balance()
|
|
64
|
+
self.assertEqual(cm.exception.code, "invalid_api_key")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
if __name__ == "__main__":
|
|
68
|
+
unittest.main()
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""The real API, in sandbox (free). Runs only with UVERIFY_TEST_KEY=uvk_test_... set."""
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import unittest
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
|
8
|
+
|
|
9
|
+
from uverify import UVerify, UVerifyError # noqa: E402
|
|
10
|
+
|
|
11
|
+
KEY = os.environ.get("UVERIFY_TEST_KEY", "")
|
|
12
|
+
JPEG = bytes.fromhex("ffd8ffe000104a46494600010100000100010000ffdb004300080606070605080707070909080a0c140d0c0b0b0c1912130f141d1a1f1e1d1a1c1c20242e2720222c231c1c2837292c30313434341f27393d38323c2e333432ffc0000b080001000101011100ffc4001400010000000000000000000000000000000affc40014100100000000000000000000000000000000ffda0008010100003f002a9fffd9")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@unittest.skipUnless(KEY.startswith("uvk_test_"), "set UVERIFY_TEST_KEY to run against the sandbox")
|
|
16
|
+
class Sandbox(unittest.TestCase):
|
|
17
|
+
def setUp(self):
|
|
18
|
+
self.u = UVerify(KEY, base_url=os.environ.get("UVERIFY_BASE_URL"))
|
|
19
|
+
|
|
20
|
+
def test_nin(self):
|
|
21
|
+
self.assertEqual(self.u.identity.nin(id_number="12345678942")["status"], "verified")
|
|
22
|
+
self.assertEqual(self.u.identity.nin(id_number="12345678900")["status"], "not_found")
|
|
23
|
+
|
|
24
|
+
def test_liveness_face_match(self):
|
|
25
|
+
s = self.u.liveness.create_session()
|
|
26
|
+
self.u.liveness.simulate(s["id"], outcome="passed")
|
|
27
|
+
v = self.u.identity.nin_face_match(id_number="12345678942", liveness_session_id=s["id"])
|
|
28
|
+
self.assertEqual((v["face_match"]["status"], v["face_match"]["liveness"]), ("matched", "passed"))
|
|
29
|
+
|
|
30
|
+
def test_the_rest(self):
|
|
31
|
+
self.assertEqual(self.u.documents.verify(front_image=JPEG, document_type="nin_card")["status"], "verified")
|
|
32
|
+
self.assertEqual(self.u.aml.screen(name="Adaeze Okafor")["status"], "clear")
|
|
33
|
+
link = self.u.kyc.create_link(customer_name="SDK Test")
|
|
34
|
+
self.assertEqual(self.u.kyc.get_link(link["id"])["status"], "pending")
|
|
35
|
+
self.assertIsInstance(self.u.account.balance()["balance"], (int, float))
|
|
36
|
+
|
|
37
|
+
def test_validation_error(self):
|
|
38
|
+
with self.assertRaises(UVerifyError) as cm:
|
|
39
|
+
self.u.identity.nin(id_number="abc")
|
|
40
|
+
self.assertEqual(cm.exception.code, "validation_error")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
if __name__ == "__main__":
|
|
44
|
+
unittest.main()
|