dominaite 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.
- dominaite-0.1.0/.github/workflows/publish.yml +28 -0
- dominaite-0.1.0/.gitignore +7 -0
- dominaite-0.1.0/PKG-INFO +206 -0
- dominaite-0.1.0/README.md +196 -0
- dominaite-0.1.0/pyproject.toml +22 -0
- dominaite-0.1.0/src/dominaite/__init__.py +29 -0
- dominaite-0.1.0/src/dominaite/client.py +328 -0
- dominaite-0.1.0/src/dominaite/exceptions.py +63 -0
- dominaite-0.1.0/tests/conftest.py +5 -0
- dominaite-0.1.0/tests/test_client.py +422 -0
- dominaite-0.1.0/tests/test_signing.py +63 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags: ["v*"]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
publish:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
environment: pypi
|
|
11
|
+
permissions:
|
|
12
|
+
id-token: write
|
|
13
|
+
contents: read
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
- uses: actions/setup-python@v5
|
|
17
|
+
with:
|
|
18
|
+
python-version: "3.12"
|
|
19
|
+
- name: Run the test suite first
|
|
20
|
+
run: |
|
|
21
|
+
python -m pip install pytest
|
|
22
|
+
python -m pytest -q
|
|
23
|
+
- name: Build sdist and wheel
|
|
24
|
+
run: |
|
|
25
|
+
python -m pip install build
|
|
26
|
+
python -m build
|
|
27
|
+
- name: Publish (trusted publishing, no tokens)
|
|
28
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
dominaite-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: dominaite
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Server-side Python client for the Dominaite merchant API: create hosted checkout sessions from your own backend.
|
|
5
|
+
License: Proprietary
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Provides-Extra: dev
|
|
8
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# dominaite-python
|
|
12
|
+
|
|
13
|
+
Server-side Python client for the Dominaite merchant API. One call from your backend opens a
|
|
14
|
+
hosted checkout session; a two-line script tag renders the payment widget on your page. Card
|
|
15
|
+
details go straight from your customer's browser into the payment widget - they never touch
|
|
16
|
+
your server, which keeps your PCI scope minimal (SAQ A).
|
|
17
|
+
|
|
18
|
+
Python 3.9+, standard library only. No `requests`, no framework, nothing to vendor.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
The package name is `dominaite` on PyPI (verified free 2026-08-17; matches `import dominaite`,
|
|
23
|
+
the same pattern Stripe uses). It is **not published yet** - until it is, install from a checkout:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install /path/to/dominaite-python-sdk
|
|
27
|
+
# or, while you are working on the SDK itself:
|
|
28
|
+
pip install -e /path/to/dominaite-python-sdk
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Credentials
|
|
32
|
+
|
|
33
|
+
You get two values from the Dominaite dashboard, under **Online payments -> Website
|
|
34
|
+
integration**, when you create an API key. The secret is shown **once** - store both like
|
|
35
|
+
passwords:
|
|
36
|
+
|
|
37
|
+
- `dmk_...` - your API key id. Identifies you; not secret by itself.
|
|
38
|
+
- `dms_...` - your API secret. Server-side only: environment variable or a config file outside
|
|
39
|
+
the web root. Never in a browser, never in git, never in logs.
|
|
40
|
+
|
|
41
|
+
Every request is signed with the secret (HMAC-SHA256) and timestamped. Keep your server clock
|
|
42
|
+
on NTP - signatures older than 5 minutes are rejected.
|
|
43
|
+
|
|
44
|
+
## Quickstart against dev
|
|
45
|
+
|
|
46
|
+
Everything you need to go from nothing to a live session on the dev environment.
|
|
47
|
+
|
|
48
|
+
**1. Set your credentials.** Both come from the dashboard's Website-integration tab (dev
|
|
49
|
+
dashboard, dev key - a prod key will not authenticate against dev):
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
export DOMINAITE_KEY_ID='dmk_...' # the key id shown on the tab
|
|
53
|
+
export DOMINAITE_SECRET='dms_...' # the secret shown once at key creation
|
|
54
|
+
export DOMINAITE_BASE_URL='https://func-dom-gw-payments-dev-gwc-01.azurewebsites.net/api'
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
That base URL is the dev payments service. Production is
|
|
58
|
+
`https://api.dominaite.com/payments`, which is the SDK's default when you pass no `base_url`.
|
|
59
|
+
|
|
60
|
+
**2. Check your signing before you call anything.** This runs offline against the published
|
|
61
|
+
test vector and authenticates nothing, so it can never fail for credential reasons:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
python -m pytest tests/test_signing.py
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
**3. Mint a session** (`mint.py`):
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
import os
|
|
71
|
+
|
|
72
|
+
from dominaite import CheckoutRefusedError, DominaiteClient, TransportError
|
|
73
|
+
|
|
74
|
+
client = DominaiteClient(
|
|
75
|
+
os.environ["DOMINAITE_KEY_ID"],
|
|
76
|
+
os.environ["DOMINAITE_SECRET"],
|
|
77
|
+
base_url=os.environ.get("DOMINAITE_BASE_URL", "https://api.dominaite.com/payments"),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
session = client.create_checkout_session(
|
|
82
|
+
amount=2500, # minor units: 2500 = 25.00 EUR
|
|
83
|
+
currency="EUR",
|
|
84
|
+
order_reference="order-1042", # your own order id, shows up in your dashboard
|
|
85
|
+
customer={
|
|
86
|
+
# Pass everything you already know - prefilled fields are hidden from the
|
|
87
|
+
# payer, so the checkout form stays short.
|
|
88
|
+
"firstName": "Ana",
|
|
89
|
+
"lastName": "Kirova",
|
|
90
|
+
"email": "ana@example.com",
|
|
91
|
+
},
|
|
92
|
+
language="bg", # widget UI language
|
|
93
|
+
theme="dark",
|
|
94
|
+
)
|
|
95
|
+
except CheckoutRefusedError as refusal:
|
|
96
|
+
# Machine-readable: refusal.error_code - see the exception docstring for the codes.
|
|
97
|
+
raise SystemExit("Payment unavailable: " + refusal.error_code)
|
|
98
|
+
except TransportError:
|
|
99
|
+
# Network blip - safe to retry with the same idempotency_key.
|
|
100
|
+
raise SystemExit("Payment temporarily unavailable")
|
|
101
|
+
|
|
102
|
+
print(session["transactionId"], session["cashierKey"], session["cashierToken"])
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
python mint.py
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
A transaction id, cashier key and cashier token on stdout means the whole chain works: your
|
|
110
|
+
credentials, your clock, your signing, and the dev gateway.
|
|
111
|
+
|
|
112
|
+
**If it fails**, the error tells you which one:
|
|
113
|
+
|
|
114
|
+
| What you see | What is wrong |
|
|
115
|
+
|---|---|
|
|
116
|
+
| `AuthenticationError` + `INVALID_API_KEY` | Wrong or revoked key id, or a prod key against dev. |
|
|
117
|
+
| `AuthenticationError` + `INVALID_SIGNATURE` | Secret does not match the key id. |
|
|
118
|
+
| `AuthenticationError` + `TIMESTAMP_OUT_OF_RANGE` | Your machine's clock is more than 5 minutes off. |
|
|
119
|
+
| `AuthenticationError` + `IP_NOT_ALLOWED` | The key has an IP allowlist that does not include you. |
|
|
120
|
+
| `CheckoutRefusedError` | You authenticated fine; the gateway declined to open a session. |
|
|
121
|
+
| `TransportError` | Wrong base URL, or the service is down. Retry with the same key. |
|
|
122
|
+
|
|
123
|
+
**4. Render the widget.** Store `session["transactionId"]` against your order, then hand the
|
|
124
|
+
two cashier values to the page:
|
|
125
|
+
|
|
126
|
+
```html
|
|
127
|
+
<div id="checkout"></div>
|
|
128
|
+
<script src="https://bp-checkout.dominaite.com/v2/launcher"
|
|
129
|
+
data-cashier-key="{{ cashier_key }}"
|
|
130
|
+
data-cashier-token="{{ cashier_token }}"></script>
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
HTML-escape both when templating (Jinja's autoescape does it for you). They are per-payment
|
|
134
|
+
session values, not your credentials.
|
|
135
|
+
|
|
136
|
+
That's the whole integration: the session call, the script tag, and your domain bound to your
|
|
137
|
+
checkout by Dominaite during onboarding.
|
|
138
|
+
|
|
139
|
+
## Amounts are minor units
|
|
140
|
+
|
|
141
|
+
`amount` is always an integer in the currency's minor unit: `2500` is 25.00 EUR. A float or a
|
|
142
|
+
string raises `ValueError` before anything is sent. The amount is locked server-side - what you
|
|
143
|
+
pass here is what gets charged; nothing in the browser can change it.
|
|
144
|
+
|
|
145
|
+
## Retries and double-charges
|
|
146
|
+
|
|
147
|
+
Every `create_checkout_session` call carries an idempotency key (auto-generated, or pass your
|
|
148
|
+
own as `idempotency_key`). Retrying with the same key never opens a second payment - on a
|
|
149
|
+
timeout, retry with the same key rather than generating a new one.
|
|
150
|
+
|
|
151
|
+
There is a helper that does exactly that:
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
session = client.create_checkout_session_with_retry(
|
|
155
|
+
amount=2500,
|
|
156
|
+
currency="EUR",
|
|
157
|
+
order_reference="order-1042",
|
|
158
|
+
max_attempts=3,
|
|
159
|
+
)
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
It retries only `TransportError` (network failures, 5xx, `MERCHANT_API_UNAVAILABLE`), reuses the
|
|
163
|
+
one key across all attempts, and backs off between them. Refusals and authentication failures
|
|
164
|
+
are raised immediately.
|
|
165
|
+
|
|
166
|
+
## Sessions expire
|
|
167
|
+
|
|
168
|
+
A session is valid for 2 hours. If the payer comes back later, create a new session.
|
|
169
|
+
|
|
170
|
+
## Status polling
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
status = client.get_status(session["transactionId"])
|
|
174
|
+
# {"transactionId": ..., "orderReference": "order-1042", "status": "succeeded",
|
|
175
|
+
# "amount": 2500, "currency": "EUR", ...}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
`status` is one of: `pending`, `processing`, `succeeded`, `failed`, `refunded`,
|
|
179
|
+
`partially_refunded`, `cancelled`, `disputed`, `abandoned`. While the session is still payable
|
|
180
|
+
the response also carries `expiresAt`; after that instant a `pending` session can only become
|
|
181
|
+
`abandoned`. An unknown transaction id raises `ApiError` with `http_status == 404`.
|
|
182
|
+
|
|
183
|
+
Poll after the payer returns to you, or on your order timeout - not in a tight loop; the
|
|
184
|
+
endpoint is rate limited per key.
|
|
185
|
+
|
|
186
|
+
## Errors
|
|
187
|
+
|
|
188
|
+
| Exception | Means | Retry? |
|
|
189
|
+
|---|---|---|
|
|
190
|
+
| `AuthenticationError` | Bad credentials, bad signature, clock skew, IP not allowlisted | No - fix config |
|
|
191
|
+
| `CheckoutRefusedError` | The gateway refused to open the session (`error_code`) | Depends on the code |
|
|
192
|
+
| `ApiError` | Unexpected response, or a 4xx like an unknown transaction id (`http_status`) | No |
|
|
193
|
+
| `TransportError` | Network failure or 5xx; you don't know if it landed | Yes, same idempotency key |
|
|
194
|
+
|
|
195
|
+
All four inherit from `DominaiteError` if you only care that the call failed.
|
|
196
|
+
|
|
197
|
+
## Running the tests
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
python -m venv .venv && .venv/bin/pip install pytest
|
|
201
|
+
.venv/bin/python -m pytest
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
`tests/test_signing.py` reproduces the signing test vector published on the dashboard's
|
|
205
|
+
Website-integration tab. If it ever fails, the SDK cannot authenticate - fix the signing, never
|
|
206
|
+
the expected value.
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# dominaite-python
|
|
2
|
+
|
|
3
|
+
Server-side Python client for the Dominaite merchant API. One call from your backend opens a
|
|
4
|
+
hosted checkout session; a two-line script tag renders the payment widget on your page. Card
|
|
5
|
+
details go straight from your customer's browser into the payment widget - they never touch
|
|
6
|
+
your server, which keeps your PCI scope minimal (SAQ A).
|
|
7
|
+
|
|
8
|
+
Python 3.9+, standard library only. No `requests`, no framework, nothing to vendor.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
The package name is `dominaite` on PyPI (verified free 2026-08-17; matches `import dominaite`,
|
|
13
|
+
the same pattern Stripe uses). It is **not published yet** - until it is, install from a checkout:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install /path/to/dominaite-python-sdk
|
|
17
|
+
# or, while you are working on the SDK itself:
|
|
18
|
+
pip install -e /path/to/dominaite-python-sdk
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Credentials
|
|
22
|
+
|
|
23
|
+
You get two values from the Dominaite dashboard, under **Online payments -> Website
|
|
24
|
+
integration**, when you create an API key. The secret is shown **once** - store both like
|
|
25
|
+
passwords:
|
|
26
|
+
|
|
27
|
+
- `dmk_...` - your API key id. Identifies you; not secret by itself.
|
|
28
|
+
- `dms_...` - your API secret. Server-side only: environment variable or a config file outside
|
|
29
|
+
the web root. Never in a browser, never in git, never in logs.
|
|
30
|
+
|
|
31
|
+
Every request is signed with the secret (HMAC-SHA256) and timestamped. Keep your server clock
|
|
32
|
+
on NTP - signatures older than 5 minutes are rejected.
|
|
33
|
+
|
|
34
|
+
## Quickstart against dev
|
|
35
|
+
|
|
36
|
+
Everything you need to go from nothing to a live session on the dev environment.
|
|
37
|
+
|
|
38
|
+
**1. Set your credentials.** Both come from the dashboard's Website-integration tab (dev
|
|
39
|
+
dashboard, dev key - a prod key will not authenticate against dev):
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
export DOMINAITE_KEY_ID='dmk_...' # the key id shown on the tab
|
|
43
|
+
export DOMINAITE_SECRET='dms_...' # the secret shown once at key creation
|
|
44
|
+
export DOMINAITE_BASE_URL='https://func-dom-gw-payments-dev-gwc-01.azurewebsites.net/api'
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
That base URL is the dev payments service. Production is
|
|
48
|
+
`https://api.dominaite.com/payments`, which is the SDK's default when you pass no `base_url`.
|
|
49
|
+
|
|
50
|
+
**2. Check your signing before you call anything.** This runs offline against the published
|
|
51
|
+
test vector and authenticates nothing, so it can never fail for credential reasons:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
python -m pytest tests/test_signing.py
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
**3. Mint a session** (`mint.py`):
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
import os
|
|
61
|
+
|
|
62
|
+
from dominaite import CheckoutRefusedError, DominaiteClient, TransportError
|
|
63
|
+
|
|
64
|
+
client = DominaiteClient(
|
|
65
|
+
os.environ["DOMINAITE_KEY_ID"],
|
|
66
|
+
os.environ["DOMINAITE_SECRET"],
|
|
67
|
+
base_url=os.environ.get("DOMINAITE_BASE_URL", "https://api.dominaite.com/payments"),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
session = client.create_checkout_session(
|
|
72
|
+
amount=2500, # minor units: 2500 = 25.00 EUR
|
|
73
|
+
currency="EUR",
|
|
74
|
+
order_reference="order-1042", # your own order id, shows up in your dashboard
|
|
75
|
+
customer={
|
|
76
|
+
# Pass everything you already know - prefilled fields are hidden from the
|
|
77
|
+
# payer, so the checkout form stays short.
|
|
78
|
+
"firstName": "Ana",
|
|
79
|
+
"lastName": "Kirova",
|
|
80
|
+
"email": "ana@example.com",
|
|
81
|
+
},
|
|
82
|
+
language="bg", # widget UI language
|
|
83
|
+
theme="dark",
|
|
84
|
+
)
|
|
85
|
+
except CheckoutRefusedError as refusal:
|
|
86
|
+
# Machine-readable: refusal.error_code - see the exception docstring for the codes.
|
|
87
|
+
raise SystemExit("Payment unavailable: " + refusal.error_code)
|
|
88
|
+
except TransportError:
|
|
89
|
+
# Network blip - safe to retry with the same idempotency_key.
|
|
90
|
+
raise SystemExit("Payment temporarily unavailable")
|
|
91
|
+
|
|
92
|
+
print(session["transactionId"], session["cashierKey"], session["cashierToken"])
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
python mint.py
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
A transaction id, cashier key and cashier token on stdout means the whole chain works: your
|
|
100
|
+
credentials, your clock, your signing, and the dev gateway.
|
|
101
|
+
|
|
102
|
+
**If it fails**, the error tells you which one:
|
|
103
|
+
|
|
104
|
+
| What you see | What is wrong |
|
|
105
|
+
|---|---|
|
|
106
|
+
| `AuthenticationError` + `INVALID_API_KEY` | Wrong or revoked key id, or a prod key against dev. |
|
|
107
|
+
| `AuthenticationError` + `INVALID_SIGNATURE` | Secret does not match the key id. |
|
|
108
|
+
| `AuthenticationError` + `TIMESTAMP_OUT_OF_RANGE` | Your machine's clock is more than 5 minutes off. |
|
|
109
|
+
| `AuthenticationError` + `IP_NOT_ALLOWED` | The key has an IP allowlist that does not include you. |
|
|
110
|
+
| `CheckoutRefusedError` | You authenticated fine; the gateway declined to open a session. |
|
|
111
|
+
| `TransportError` | Wrong base URL, or the service is down. Retry with the same key. |
|
|
112
|
+
|
|
113
|
+
**4. Render the widget.** Store `session["transactionId"]` against your order, then hand the
|
|
114
|
+
two cashier values to the page:
|
|
115
|
+
|
|
116
|
+
```html
|
|
117
|
+
<div id="checkout"></div>
|
|
118
|
+
<script src="https://bp-checkout.dominaite.com/v2/launcher"
|
|
119
|
+
data-cashier-key="{{ cashier_key }}"
|
|
120
|
+
data-cashier-token="{{ cashier_token }}"></script>
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
HTML-escape both when templating (Jinja's autoescape does it for you). They are per-payment
|
|
124
|
+
session values, not your credentials.
|
|
125
|
+
|
|
126
|
+
That's the whole integration: the session call, the script tag, and your domain bound to your
|
|
127
|
+
checkout by Dominaite during onboarding.
|
|
128
|
+
|
|
129
|
+
## Amounts are minor units
|
|
130
|
+
|
|
131
|
+
`amount` is always an integer in the currency's minor unit: `2500` is 25.00 EUR. A float or a
|
|
132
|
+
string raises `ValueError` before anything is sent. The amount is locked server-side - what you
|
|
133
|
+
pass here is what gets charged; nothing in the browser can change it.
|
|
134
|
+
|
|
135
|
+
## Retries and double-charges
|
|
136
|
+
|
|
137
|
+
Every `create_checkout_session` call carries an idempotency key (auto-generated, or pass your
|
|
138
|
+
own as `idempotency_key`). Retrying with the same key never opens a second payment - on a
|
|
139
|
+
timeout, retry with the same key rather than generating a new one.
|
|
140
|
+
|
|
141
|
+
There is a helper that does exactly that:
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
session = client.create_checkout_session_with_retry(
|
|
145
|
+
amount=2500,
|
|
146
|
+
currency="EUR",
|
|
147
|
+
order_reference="order-1042",
|
|
148
|
+
max_attempts=3,
|
|
149
|
+
)
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
It retries only `TransportError` (network failures, 5xx, `MERCHANT_API_UNAVAILABLE`), reuses the
|
|
153
|
+
one key across all attempts, and backs off between them. Refusals and authentication failures
|
|
154
|
+
are raised immediately.
|
|
155
|
+
|
|
156
|
+
## Sessions expire
|
|
157
|
+
|
|
158
|
+
A session is valid for 2 hours. If the payer comes back later, create a new session.
|
|
159
|
+
|
|
160
|
+
## Status polling
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
status = client.get_status(session["transactionId"])
|
|
164
|
+
# {"transactionId": ..., "orderReference": "order-1042", "status": "succeeded",
|
|
165
|
+
# "amount": 2500, "currency": "EUR", ...}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
`status` is one of: `pending`, `processing`, `succeeded`, `failed`, `refunded`,
|
|
169
|
+
`partially_refunded`, `cancelled`, `disputed`, `abandoned`. While the session is still payable
|
|
170
|
+
the response also carries `expiresAt`; after that instant a `pending` session can only become
|
|
171
|
+
`abandoned`. An unknown transaction id raises `ApiError` with `http_status == 404`.
|
|
172
|
+
|
|
173
|
+
Poll after the payer returns to you, or on your order timeout - not in a tight loop; the
|
|
174
|
+
endpoint is rate limited per key.
|
|
175
|
+
|
|
176
|
+
## Errors
|
|
177
|
+
|
|
178
|
+
| Exception | Means | Retry? |
|
|
179
|
+
|---|---|---|
|
|
180
|
+
| `AuthenticationError` | Bad credentials, bad signature, clock skew, IP not allowlisted | No - fix config |
|
|
181
|
+
| `CheckoutRefusedError` | The gateway refused to open the session (`error_code`) | Depends on the code |
|
|
182
|
+
| `ApiError` | Unexpected response, or a 4xx like an unknown transaction id (`http_status`) | No |
|
|
183
|
+
| `TransportError` | Network failure or 5xx; you don't know if it landed | Yes, same idempotency key |
|
|
184
|
+
|
|
185
|
+
All four inherit from `DominaiteError` if you only care that the call failed.
|
|
186
|
+
|
|
187
|
+
## Running the tests
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
python -m venv .venv && .venv/bin/pip install pytest
|
|
191
|
+
.venv/bin/python -m pytest
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
`tests/test_signing.py` reproduces the signing test vector published on the dashboard's
|
|
195
|
+
Website-integration tab. If it ever fails, the SDK cannot authenticate - fix the signing, never
|
|
196
|
+
the expected value.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
# PyPI name settled as "dominaite" (verified free 2026-08-17); matches the import name.
|
|
7
|
+
name = "dominaite"
|
|
8
|
+
version = "0.1.0"
|
|
9
|
+
description = "Server-side Python client for the Dominaite merchant API: create hosted checkout sessions from your own backend."
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "Proprietary" }
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
dependencies = []
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
dev = ["pytest>=7.0"]
|
|
17
|
+
|
|
18
|
+
[tool.hatch.build.targets.wheel]
|
|
19
|
+
packages = ["src/dominaite"]
|
|
20
|
+
|
|
21
|
+
[tool.pytest.ini_options]
|
|
22
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Server-side Python client for the Dominaite merchant API."""
|
|
2
|
+
|
|
3
|
+
from .client import (
|
|
4
|
+
DEFAULT_BASE_URL,
|
|
5
|
+
SESSIONS_PATH,
|
|
6
|
+
DominaiteClient,
|
|
7
|
+
__version__,
|
|
8
|
+
sign_request,
|
|
9
|
+
)
|
|
10
|
+
from .exceptions import (
|
|
11
|
+
ApiError,
|
|
12
|
+
AuthenticationError,
|
|
13
|
+
CheckoutRefusedError,
|
|
14
|
+
DominaiteError,
|
|
15
|
+
TransportError,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"DEFAULT_BASE_URL",
|
|
20
|
+
"SESSIONS_PATH",
|
|
21
|
+
"ApiError",
|
|
22
|
+
"AuthenticationError",
|
|
23
|
+
"CheckoutRefusedError",
|
|
24
|
+
"DominaiteClient",
|
|
25
|
+
"DominaiteError",
|
|
26
|
+
"TransportError",
|
|
27
|
+
"__version__",
|
|
28
|
+
"sign_request",
|
|
29
|
+
]
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""Server-side client for the Dominaite merchant API."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import secrets
|
|
8
|
+
import time
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.request
|
|
11
|
+
from typing import Any, Dict, Mapping, Optional
|
|
12
|
+
|
|
13
|
+
from .exceptions import (
|
|
14
|
+
ApiError,
|
|
15
|
+
AuthenticationError,
|
|
16
|
+
CheckoutRefusedError,
|
|
17
|
+
TransportError,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__version__ = "0.1.0"
|
|
21
|
+
|
|
22
|
+
DEFAULT_BASE_URL = "https://api.dominaite.com/payments"
|
|
23
|
+
SESSIONS_PATH = "/merchant-api/bridgerpay/checkout/sessions"
|
|
24
|
+
DEFAULT_TIMEOUT_SECONDS = 45.0 # serverless cold starts hit 10+s on dev; 15s was a coin flip
|
|
25
|
+
|
|
26
|
+
_TRANSACTION_ID_RE = re.compile(
|
|
27
|
+
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def sign_request(
|
|
32
|
+
secret: str,
|
|
33
|
+
timestamp: str,
|
|
34
|
+
method: str,
|
|
35
|
+
path: str,
|
|
36
|
+
idempotency_key: str,
|
|
37
|
+
body: str,
|
|
38
|
+
) -> str:
|
|
39
|
+
"""Build the ``X-Signature`` value for one request.
|
|
40
|
+
|
|
41
|
+
Lowercase hex HMAC-SHA256 over
|
|
42
|
+
``"{timestamp}\\n{METHOD}\\n{path}\\n{idempotencyKey}\\n{sha256hex(body)}"``.
|
|
43
|
+
|
|
44
|
+
The idempotency key is INSIDE the signature, so a captured request cannot be
|
|
45
|
+
replayed with a different key to mint extra sessions. The server rejects
|
|
46
|
+
timestamps more than 5 minutes off - keep your server clock on NTP.
|
|
47
|
+
|
|
48
|
+
Exposed so you can pin it in your own test against the published vector.
|
|
49
|
+
"""
|
|
50
|
+
body_hash = hashlib.sha256(body.encode("utf-8")).hexdigest()
|
|
51
|
+
payload = "\n".join([timestamp, method.upper(), path, idempotency_key, body_hash])
|
|
52
|
+
return hmac.new(
|
|
53
|
+
secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256
|
|
54
|
+
).hexdigest()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class DominaiteClient:
|
|
58
|
+
"""Server-side client for the Dominaite merchant API.
|
|
59
|
+
|
|
60
|
+
Keep your API secret on the server. Never ship it to a browser, never commit it,
|
|
61
|
+
never log it. Card details never touch your backend or this SDK - the payer enters
|
|
62
|
+
them inside the hosted checkout widget.
|
|
63
|
+
|
|
64
|
+
Usage::
|
|
65
|
+
|
|
66
|
+
client = DominaiteClient(os.environ["DOMINAITE_KEY_ID"], os.environ["DOMINAITE_SECRET"])
|
|
67
|
+
session = client.create_checkout_session(
|
|
68
|
+
amount=2500, # minor units: 25.00 EUR
|
|
69
|
+
currency="EUR",
|
|
70
|
+
order_reference="order-1042",
|
|
71
|
+
customer={"firstName": "Ana", "lastName": "K", "email": "ana@example.com"},
|
|
72
|
+
)
|
|
73
|
+
# Hand session["cashierKey"] + session["cashierToken"] to the embed snippet.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
key_id: str,
|
|
79
|
+
secret: str,
|
|
80
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
81
|
+
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|
82
|
+
) -> None:
|
|
83
|
+
"""
|
|
84
|
+
:param key_id: Your API key id (``dmk_...``), from the Dominaite dashboard.
|
|
85
|
+
:param secret: Your API secret (``dms_...``). Server-side only.
|
|
86
|
+
:param base_url: Override for non-production environments.
|
|
87
|
+
:param timeout: Per-request socket timeout in seconds.
|
|
88
|
+
"""
|
|
89
|
+
if not key_id.startswith("dmk_"):
|
|
90
|
+
raise ValueError("key_id must start with dmk_")
|
|
91
|
+
if not secret.startswith("dms_"):
|
|
92
|
+
raise ValueError("secret must start with dms_")
|
|
93
|
+
self._key_id = key_id
|
|
94
|
+
self._secret = secret
|
|
95
|
+
self._base_url = base_url.rstrip("/")
|
|
96
|
+
self._timeout = timeout
|
|
97
|
+
|
|
98
|
+
def create_checkout_session(
|
|
99
|
+
self,
|
|
100
|
+
amount: int,
|
|
101
|
+
currency: str,
|
|
102
|
+
order_reference: str,
|
|
103
|
+
customer: Optional[Mapping[str, Any]] = None,
|
|
104
|
+
country: Optional[str] = None,
|
|
105
|
+
language: Optional[str] = None,
|
|
106
|
+
theme: Optional[str] = None,
|
|
107
|
+
description: Optional[str] = None,
|
|
108
|
+
idempotency_key: Optional[str] = None,
|
|
109
|
+
) -> Dict[str, Any]:
|
|
110
|
+
"""Create a hosted checkout session for one payment.
|
|
111
|
+
|
|
112
|
+
:param amount: Integer in MINOR units (2500 = 25.00 EUR). Never a float.
|
|
113
|
+
:param currency: ISO 4217 code.
|
|
114
|
+
:param order_reference: Your own order id, 100 chars or fewer.
|
|
115
|
+
:param customer: ``{"firstName", "lastName", "email", "phone"}``. Pass everything
|
|
116
|
+
you already know - prefilled fields are hidden from the payer, so the
|
|
117
|
+
checkout form stays short.
|
|
118
|
+
:param country: ISO 3166-1 alpha-2.
|
|
119
|
+
:param language: ISO 639-1, the widget UI language.
|
|
120
|
+
:param theme: ``light``, ``dark``, or ``bright``.
|
|
121
|
+
:param description: Free-text description shown on the checkout.
|
|
122
|
+
:param idempotency_key: Auto-generated when omitted. Retrying with the same key
|
|
123
|
+
never creates a second payment.
|
|
124
|
+
:returns: ``{"transactionId", "orderId", "cashierKey", "cashierToken", "amount",
|
|
125
|
+
"currency", "expiresAt"}``.
|
|
126
|
+
|
|
127
|
+
:raises AuthenticationError: Wrong/revoked credentials or bad signature
|
|
128
|
+
(fix config; do not retry).
|
|
129
|
+
:raises CheckoutRefusedError: The gateway refused the session (inspect
|
|
130
|
+
``error_code``).
|
|
131
|
+
:raises ApiError: Unexpected API response.
|
|
132
|
+
:raises TransportError: Network-level failure (safe to retry WITH the same
|
|
133
|
+
``idempotency_key``).
|
|
134
|
+
"""
|
|
135
|
+
# bool is a subclass of int in Python, so True would otherwise sail through
|
|
136
|
+
# and get serialized as `true`.
|
|
137
|
+
if isinstance(amount, bool) or not isinstance(amount, int) or amount <= 0:
|
|
138
|
+
raise ValueError(
|
|
139
|
+
"amount must be a positive integer in MINOR units (e.g. 2500 for 25.00 EUR)"
|
|
140
|
+
)
|
|
141
|
+
if not currency:
|
|
142
|
+
raise ValueError("currency is required")
|
|
143
|
+
if not order_reference:
|
|
144
|
+
raise ValueError("order_reference is required")
|
|
145
|
+
|
|
146
|
+
key = idempotency_key if idempotency_key is not None else secrets.token_hex(16)
|
|
147
|
+
if not isinstance(key, str) or not key or len(key) > 100:
|
|
148
|
+
raise ValueError(
|
|
149
|
+
"idempotency_key must be a non-empty string of at most 100 characters"
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
body: Dict[str, Any] = {
|
|
153
|
+
"amount": amount,
|
|
154
|
+
"currency": currency,
|
|
155
|
+
"orderReference": order_reference,
|
|
156
|
+
}
|
|
157
|
+
if customer is not None:
|
|
158
|
+
body["customer"] = dict(customer)
|
|
159
|
+
if country is not None:
|
|
160
|
+
body["country"] = country
|
|
161
|
+
if language is not None:
|
|
162
|
+
body["language"] = language
|
|
163
|
+
if theme is not None:
|
|
164
|
+
body["theme"] = theme
|
|
165
|
+
if description is not None:
|
|
166
|
+
body["description"] = description
|
|
167
|
+
|
|
168
|
+
response = self._request("POST", SESSIONS_PATH, body, key)
|
|
169
|
+
|
|
170
|
+
if response.get("success") is not True or "checkout" not in response:
|
|
171
|
+
raise CheckoutRefusedError(
|
|
172
|
+
str(response.get("errorCode") or "UNKNOWN"),
|
|
173
|
+
str(response.get("errorMessage") or "The checkout session was refused."),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
return response["checkout"]
|
|
177
|
+
|
|
178
|
+
def create_checkout_session_with_retry(
|
|
179
|
+
self,
|
|
180
|
+
max_attempts: int = 3,
|
|
181
|
+
backoff_seconds: float = 0.5,
|
|
182
|
+
**kwargs: Any,
|
|
183
|
+
) -> Dict[str, Any]:
|
|
184
|
+
"""Create a session, retrying transport failures with the SAME idempotency key.
|
|
185
|
+
|
|
186
|
+
A ``TransportError`` (network blip, 5xx, ``MERCHANT_API_UNAVAILABLE``) leaves you
|
|
187
|
+
not knowing whether the request landed. Reusing the key is what makes the retry
|
|
188
|
+
safe: the server returns the original session instead of opening a second one.
|
|
189
|
+
Generating a fresh key here would be the double-charge bug this method exists to
|
|
190
|
+
prevent.
|
|
191
|
+
|
|
192
|
+
Refusals and authentication failures are raised immediately - retrying them just
|
|
193
|
+
burns time.
|
|
194
|
+
|
|
195
|
+
:param max_attempts: Total attempts including the first one.
|
|
196
|
+
:param backoff_seconds: Base delay; doubles after each failed attempt.
|
|
197
|
+
:param kwargs: Passed straight to :meth:`create_checkout_session`.
|
|
198
|
+
"""
|
|
199
|
+
if max_attempts < 1:
|
|
200
|
+
raise ValueError("max_attempts must be at least 1")
|
|
201
|
+
|
|
202
|
+
kwargs.setdefault("idempotency_key", secrets.token_hex(16))
|
|
203
|
+
|
|
204
|
+
delay = backoff_seconds
|
|
205
|
+
for attempt in range(1, max_attempts + 1):
|
|
206
|
+
try:
|
|
207
|
+
return self.create_checkout_session(**kwargs)
|
|
208
|
+
except TransportError:
|
|
209
|
+
if attempt == max_attempts:
|
|
210
|
+
raise
|
|
211
|
+
if delay > 0:
|
|
212
|
+
time.sleep(delay)
|
|
213
|
+
delay *= 2
|
|
214
|
+
|
|
215
|
+
raise AssertionError("unreachable")
|
|
216
|
+
|
|
217
|
+
def get_status(self, transaction_id: str) -> Dict[str, Any]:
|
|
218
|
+
"""Read the payment status of one of your checkout sessions.
|
|
219
|
+
|
|
220
|
+
Status values: ``pending``, ``processing``, ``succeeded``, ``failed``,
|
|
221
|
+
``refunded``, ``partially_refunded``, ``cancelled``, ``disputed``,
|
|
222
|
+
``abandoned``. While a session is still payable the response carries
|
|
223
|
+
``expiresAt``; amounts are integers in MINOR units.
|
|
224
|
+
|
|
225
|
+
:param transaction_id: The ``transactionId`` from
|
|
226
|
+
:meth:`create_checkout_session`.
|
|
227
|
+
|
|
228
|
+
:raises AuthenticationError: Wrong/revoked credentials or bad signature.
|
|
229
|
+
:raises ApiError: Unknown transaction id (HTTP 404) or unexpected response.
|
|
230
|
+
:raises TransportError: Network-level failure (safe to retry).
|
|
231
|
+
"""
|
|
232
|
+
normalized = transaction_id.strip().lower()
|
|
233
|
+
if not _TRANSACTION_ID_RE.match(normalized):
|
|
234
|
+
raise ValueError(
|
|
235
|
+
"transaction_id must be the UUID returned by create_checkout_session()"
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
return self._request("GET", SESSIONS_PATH + "/" + normalized, None, "")
|
|
239
|
+
|
|
240
|
+
def _request(
|
|
241
|
+
self,
|
|
242
|
+
method: str,
|
|
243
|
+
path: str,
|
|
244
|
+
body: Optional[Mapping[str, Any]],
|
|
245
|
+
idempotency_key: str,
|
|
246
|
+
) -> Dict[str, Any]:
|
|
247
|
+
"""Sign and send one request.
|
|
248
|
+
|
|
249
|
+
``body`` is None for GET: an empty body (and an empty idempotency key) is what
|
|
250
|
+
gets signed.
|
|
251
|
+
"""
|
|
252
|
+
if body is None:
|
|
253
|
+
payload = ""
|
|
254
|
+
else:
|
|
255
|
+
# Compact separators and no ASCII escaping: the bytes we hash must be the
|
|
256
|
+
# exact bytes we send, so the JSON is serialized once and reused.
|
|
257
|
+
payload = json.dumps(body, separators=(",", ":"), ensure_ascii=False)
|
|
258
|
+
|
|
259
|
+
timestamp = str(int(time.time()))
|
|
260
|
+
signature = sign_request(
|
|
261
|
+
self._secret, timestamp, method, path, idempotency_key, payload
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
headers = {
|
|
265
|
+
"Content-Type": "application/json",
|
|
266
|
+
"X-Api-Key-Id": self._key_id,
|
|
267
|
+
"X-Timestamp": timestamp,
|
|
268
|
+
"X-Signature": signature,
|
|
269
|
+
# Some edges block requests without a real User-Agent - always send one.
|
|
270
|
+
"User-Agent": "dominaite-python/" + __version__,
|
|
271
|
+
}
|
|
272
|
+
if idempotency_key:
|
|
273
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
274
|
+
|
|
275
|
+
data = payload.encode("utf-8") if body is not None else None
|
|
276
|
+
request = urllib.request.Request(
|
|
277
|
+
self._base_url + path, data=data, headers=headers, method=method
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
try:
|
|
281
|
+
with urllib.request.urlopen(request, timeout=self._timeout) as response:
|
|
282
|
+
status = response.status
|
|
283
|
+
raw = response.read().decode("utf-8", errors="replace")
|
|
284
|
+
except urllib.error.HTTPError as error:
|
|
285
|
+
# 4xx/5xx: the body still carries the machine-readable code.
|
|
286
|
+
status = error.code
|
|
287
|
+
raw = error.read().decode("utf-8", errors="replace")
|
|
288
|
+
except (urllib.error.URLError, OSError) as error:
|
|
289
|
+
raise TransportError(
|
|
290
|
+
"Could not reach the Dominaite API: {0}".format(error)
|
|
291
|
+
) from error
|
|
292
|
+
|
|
293
|
+
try:
|
|
294
|
+
decoded = json.loads(raw)
|
|
295
|
+
except ValueError:
|
|
296
|
+
decoded = None
|
|
297
|
+
if not isinstance(decoded, dict):
|
|
298
|
+
raise ApiError(status, "The API returned a non-JSON response")
|
|
299
|
+
|
|
300
|
+
# The gateway wraps responses as { success, data, ... }; unwrap when present.
|
|
301
|
+
# Error responses carry the machine-readable code at error.code.
|
|
302
|
+
inner = decoded.get("data")
|
|
303
|
+
result = inner if isinstance(inner, dict) else decoded
|
|
304
|
+
envelope_error = decoded.get("error")
|
|
305
|
+
if not isinstance(envelope_error, dict):
|
|
306
|
+
envelope_error = {}
|
|
307
|
+
|
|
308
|
+
if status in (401, 403):
|
|
309
|
+
raise AuthenticationError(
|
|
310
|
+
str(result.get("errorCode") or envelope_error.get("code") or "UNAUTHORIZED"),
|
|
311
|
+
"Authentication failed - check your key id, secret, and server clock.",
|
|
312
|
+
)
|
|
313
|
+
if status >= 500:
|
|
314
|
+
raise TransportError(
|
|
315
|
+
"The Dominaite API is unavailable (HTTP {0}); "
|
|
316
|
+
"retry with the same idempotency key.".format(status)
|
|
317
|
+
)
|
|
318
|
+
if status >= 400:
|
|
319
|
+
raise ApiError(
|
|
320
|
+
status,
|
|
321
|
+
str(
|
|
322
|
+
result.get("errorMessage")
|
|
323
|
+
or envelope_error.get("message")
|
|
324
|
+
or "Request rejected"
|
|
325
|
+
),
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
return result
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Exceptions raised by the Dominaite merchant API client.
|
|
2
|
+
|
|
3
|
+
The split matters when you write your error handling: a CheckoutRefused means the
|
|
4
|
+
gateway understood you and said no, a TransportError means you do not know whether
|
|
5
|
+
the request landed. Only the second one is safe to retry.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DominaiteError(Exception):
|
|
10
|
+
"""Base class for every error this SDK raises.
|
|
11
|
+
|
|
12
|
+
Catch this if you only care that the payment call failed. Catch the subclasses
|
|
13
|
+
when you want to branch on why.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ApiError(DominaiteError):
|
|
18
|
+
"""The API answered, but with an unexpected or rejecting response."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, http_status: int, message: str) -> None:
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
self.http_status = http_status
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AuthenticationError(DominaiteError):
|
|
26
|
+
"""The API rejected your credentials or signature.
|
|
27
|
+
|
|
28
|
+
Not retryable - fix the key id, secret, or server clock. Machine-readable code
|
|
29
|
+
on ``error_code``:
|
|
30
|
+
|
|
31
|
+
- ``INVALID_API_KEY``: wrong or revoked key id.
|
|
32
|
+
- ``INVALID_SIGNATURE``: your signing is wrong; re-run the test vector.
|
|
33
|
+
- ``TIMESTAMP_OUT_OF_RANGE``: server clock is off; fix NTP, do not retry-loop.
|
|
34
|
+
- ``IP_NOT_ALLOWED``: this key is locked to addresses that don't include yours.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, error_code: str, message: str) -> None:
|
|
38
|
+
super().__init__(message)
|
|
39
|
+
self.error_code = error_code
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class CheckoutRefusedError(DominaiteError):
|
|
43
|
+
"""The gateway understood the request but refused to open a checkout session.
|
|
44
|
+
|
|
45
|
+
Branch on ``error_code``:
|
|
46
|
+
|
|
47
|
+
- ``PAYMENT_PROCESSING_UNAVAILABLE``: card payments are off right now; retry later.
|
|
48
|
+
- ``DUPLICATE_REQUEST``: a session for this idempotency key is already open.
|
|
49
|
+
- ``ALREADY_PROCESSED``: this idempotency key's payment already completed.
|
|
50
|
+
- ``IDEMPOTENCY_KEY_REUSED``: same key sent with a DIFFERENT body; use a fresh key.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, error_code: str, message: str) -> None:
|
|
54
|
+
super().__init__(message)
|
|
55
|
+
self.error_code = error_code
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class TransportError(DominaiteError):
|
|
59
|
+
"""Network-level failure or a 5xx.
|
|
60
|
+
|
|
61
|
+
The request may or may not have reached the API. Safe to retry WITH THE SAME
|
|
62
|
+
idempotency key; a retried key never creates a second payment.
|
|
63
|
+
"""
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
"""Contract tests for DominaiteClient: what it sends, and how it classifies answers."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import io
|
|
5
|
+
import json
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.request
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from dominaite import (
|
|
12
|
+
SESSIONS_PATH,
|
|
13
|
+
ApiError,
|
|
14
|
+
AuthenticationError,
|
|
15
|
+
CheckoutRefusedError,
|
|
16
|
+
DominaiteClient,
|
|
17
|
+
TransportError,
|
|
18
|
+
sign_request,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
KEY_ID = "dmk_0123456789abcdef0123456789abcdef"
|
|
22
|
+
SECRET = "dms_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
|
23
|
+
BASE_URL = "https://api.example.test/payments"
|
|
24
|
+
TRANSACTION_ID = "11111111-2222-4333-8444-555555555555"
|
|
25
|
+
|
|
26
|
+
EMPTY_SHA256 = hashlib.sha256(b"").hexdigest()
|
|
27
|
+
|
|
28
|
+
CHECKOUT = {
|
|
29
|
+
"transactionId": TRANSACTION_ID,
|
|
30
|
+
"orderId": "ord_1",
|
|
31
|
+
"cashierKey": "ck_1",
|
|
32
|
+
"cashierToken": "ct_1",
|
|
33
|
+
"amount": 2500,
|
|
34
|
+
"currency": "EUR",
|
|
35
|
+
"expiresAt": "2026-08-16T12:00:00Z",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _Response:
|
|
40
|
+
def __init__(self, status, payload):
|
|
41
|
+
self.status = status
|
|
42
|
+
self._body = json.dumps(payload).encode("utf-8")
|
|
43
|
+
|
|
44
|
+
def read(self):
|
|
45
|
+
return self._body
|
|
46
|
+
|
|
47
|
+
def __enter__(self):
|
|
48
|
+
return self
|
|
49
|
+
|
|
50
|
+
def __exit__(self, *exc):
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class _Recorder:
|
|
55
|
+
"""Stands in for urlopen and records every request the client builds."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, *outcomes):
|
|
58
|
+
self.outcomes = list(outcomes)
|
|
59
|
+
self.requests = []
|
|
60
|
+
|
|
61
|
+
def __call__(self, request, timeout=None):
|
|
62
|
+
self.requests.append(request)
|
|
63
|
+
outcome = self.outcomes.pop(0) if len(self.outcomes) > 1 else self.outcomes[0]
|
|
64
|
+
if isinstance(outcome, Exception):
|
|
65
|
+
raise outcome
|
|
66
|
+
status, payload = outcome
|
|
67
|
+
if status >= 400:
|
|
68
|
+
raise urllib.error.HTTPError(
|
|
69
|
+
request.full_url,
|
|
70
|
+
status,
|
|
71
|
+
"error",
|
|
72
|
+
{},
|
|
73
|
+
io.BytesIO(json.dumps(payload).encode("utf-8")),
|
|
74
|
+
)
|
|
75
|
+
return _Response(status, payload)
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def last(self):
|
|
79
|
+
return self.requests[-1]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@pytest.fixture
|
|
83
|
+
def client():
|
|
84
|
+
return DominaiteClient(KEY_ID, SECRET, base_url=BASE_URL)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@pytest.fixture
|
|
88
|
+
def urlopen(monkeypatch):
|
|
89
|
+
def install(*outcomes):
|
|
90
|
+
recorder = _Recorder(*outcomes)
|
|
91
|
+
monkeypatch.setattr(urllib.request, "urlopen", recorder)
|
|
92
|
+
return recorder
|
|
93
|
+
|
|
94
|
+
return install
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _ok():
|
|
98
|
+
return (200, {"success": True, "checkout": CHECKOUT})
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _headers(request):
|
|
102
|
+
# urllib title-cases header names on the Request object.
|
|
103
|
+
return {name.lower(): value for name, value in request.headers.items()}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# --- constructor -------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_rejects_key_id_without_dmk_prefix():
|
|
110
|
+
with pytest.raises(ValueError, match="dmk_"):
|
|
111
|
+
DominaiteClient("nope", SECRET)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_rejects_secret_without_dms_prefix():
|
|
115
|
+
with pytest.raises(ValueError, match="dms_"):
|
|
116
|
+
DominaiteClient(KEY_ID, "nope")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# --- what goes on the wire ---------------------------------------------------
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_post_sends_signed_headers_and_compact_json(client, urlopen):
|
|
123
|
+
recorder = urlopen(_ok())
|
|
124
|
+
|
|
125
|
+
client.create_checkout_session(
|
|
126
|
+
amount=2500,
|
|
127
|
+
currency="EUR",
|
|
128
|
+
order_reference="order-1042",
|
|
129
|
+
idempotency_key="00000000-0000-4000-8000-000000000001",
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
request = recorder.last
|
|
133
|
+
headers = _headers(request)
|
|
134
|
+
assert request.full_url == BASE_URL + SESSIONS_PATH
|
|
135
|
+
assert request.get_method() == "POST"
|
|
136
|
+
assert headers["x-api-key-id"] == KEY_ID
|
|
137
|
+
assert headers["idempotency-key"] == "00000000-0000-4000-8000-000000000001"
|
|
138
|
+
assert headers["content-type"] == "application/json"
|
|
139
|
+
|
|
140
|
+
body = request.data.decode("utf-8")
|
|
141
|
+
assert body == '{"amount":2500,"currency":"EUR","orderReference":"order-1042"}'
|
|
142
|
+
|
|
143
|
+
expected = sign_request(
|
|
144
|
+
SECRET,
|
|
145
|
+
headers["x-timestamp"],
|
|
146
|
+
"POST",
|
|
147
|
+
SESSIONS_PATH,
|
|
148
|
+
"00000000-0000-4000-8000-000000000001",
|
|
149
|
+
body,
|
|
150
|
+
)
|
|
151
|
+
assert headers["x-signature"] == expected
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_post_reproduces_the_published_vector_end_to_end(client, urlopen, monkeypatch):
|
|
155
|
+
"""The signature the client actually puts on the wire, against the published vector.
|
|
156
|
+
|
|
157
|
+
test_signing.py pins the sign_request() function; this pins the whole path -
|
|
158
|
+
argument order, JSON serialization, header assembly - to the same answer.
|
|
159
|
+
"""
|
|
160
|
+
monkeypatch.setattr("dominaite.client.time.time", lambda: 1755302400)
|
|
161
|
+
recorder = urlopen(_ok())
|
|
162
|
+
|
|
163
|
+
client.create_checkout_session(
|
|
164
|
+
amount=2500,
|
|
165
|
+
currency="EUR",
|
|
166
|
+
order_reference="order-1042",
|
|
167
|
+
idempotency_key="00000000-0000-4000-8000-000000000001",
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
headers = _headers(recorder.last)
|
|
171
|
+
assert headers["x-timestamp"] == "1755302400"
|
|
172
|
+
assert (
|
|
173
|
+
headers["x-signature"]
|
|
174
|
+
== "95759958a0a0a9bd3e6e37101c01e8e7fee1166406e4ac2ff488764f5f742cbf"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_get_status_signs_empty_idempotency_key_and_empty_body(client, urlopen):
|
|
179
|
+
recorder = urlopen((200, {"transactionId": TRANSACTION_ID, "status": "succeeded"}))
|
|
180
|
+
|
|
181
|
+
client.get_status(TRANSACTION_ID)
|
|
182
|
+
|
|
183
|
+
request = recorder.last
|
|
184
|
+
headers = _headers(request)
|
|
185
|
+
path = SESSIONS_PATH + "/" + TRANSACTION_ID
|
|
186
|
+
|
|
187
|
+
assert request.get_method() == "GET"
|
|
188
|
+
assert request.data is None
|
|
189
|
+
assert "idempotency-key" not in headers
|
|
190
|
+
|
|
191
|
+
# The signed payload uses an EMPTY idempotency key and the hash of an EMPTY body.
|
|
192
|
+
expected = sign_request(SECRET, headers["x-timestamp"], "GET", path, "", "")
|
|
193
|
+
assert headers["x-signature"] == expected
|
|
194
|
+
|
|
195
|
+
payload = "\n".join([headers["x-timestamp"], "GET", path, "", EMPTY_SHA256])
|
|
196
|
+
assert payload.count("\n") == 4
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_get_status_normalizes_transaction_id_casing(client, urlopen):
|
|
200
|
+
recorder = urlopen((200, {"transactionId": TRANSACTION_ID}))
|
|
201
|
+
|
|
202
|
+
client.get_status(" " + TRANSACTION_ID.upper() + " ")
|
|
203
|
+
|
|
204
|
+
assert recorder.last.full_url.endswith(SESSIONS_PATH + "/" + TRANSACTION_ID)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def test_get_status_rejects_non_uuid(client):
|
|
208
|
+
with pytest.raises(ValueError, match="UUID"):
|
|
209
|
+
client.get_status("order-1042")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def test_optional_fields_are_omitted_when_not_passed(client, urlopen):
|
|
213
|
+
recorder = urlopen(_ok())
|
|
214
|
+
|
|
215
|
+
client.create_checkout_session(
|
|
216
|
+
amount=2500,
|
|
217
|
+
currency="EUR",
|
|
218
|
+
order_reference="order-1042",
|
|
219
|
+
customer={"firstName": "Ana", "email": "ana@example.com"},
|
|
220
|
+
language="bg",
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
body = json.loads(recorder.last.data.decode("utf-8"))
|
|
224
|
+
assert body["customer"] == {"firstName": "Ana", "email": "ana@example.com"}
|
|
225
|
+
assert body["language"] == "bg"
|
|
226
|
+
assert "theme" not in body
|
|
227
|
+
assert "country" not in body
|
|
228
|
+
assert "description" not in body
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def test_generates_an_idempotency_key_when_none_is_given(client, urlopen):
|
|
232
|
+
recorder = urlopen(_ok())
|
|
233
|
+
|
|
234
|
+
client.create_checkout_session(amount=2500, currency="EUR", order_reference="o-1")
|
|
235
|
+
client.create_checkout_session(amount=2500, currency="EUR", order_reference="o-2")
|
|
236
|
+
|
|
237
|
+
first = _headers(recorder.requests[0])["idempotency-key"]
|
|
238
|
+
second = _headers(recorder.requests[1])["idempotency-key"]
|
|
239
|
+
assert first and second and first != second
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# --- amounts -----------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@pytest.mark.parametrize("amount", [25.0, "2500", 0, -1, True])
|
|
246
|
+
def test_amount_must_be_a_positive_integer_in_minor_units(client, amount):
|
|
247
|
+
with pytest.raises(ValueError, match="MINOR units"):
|
|
248
|
+
client.create_checkout_session(
|
|
249
|
+
amount=amount, currency="EUR", order_reference="order-1042"
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# --- refusal vs transport ----------------------------------------------------
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def test_business_refusal_raises_checkout_refused_with_the_error_code(client, urlopen):
|
|
257
|
+
urlopen(
|
|
258
|
+
(
|
|
259
|
+
200,
|
|
260
|
+
{
|
|
261
|
+
"success": False,
|
|
262
|
+
"errorCode": "PAYMENT_PROCESSING_UNAVAILABLE",
|
|
263
|
+
"errorMessage": "Card payments are off",
|
|
264
|
+
},
|
|
265
|
+
)
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
with pytest.raises(CheckoutRefusedError) as caught:
|
|
269
|
+
client.create_checkout_session(
|
|
270
|
+
amount=2500, currency="EUR", order_reference="order-1042"
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
assert caught.value.error_code == "PAYMENT_PROCESSING_UNAVAILABLE"
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def test_503_raises_transport_not_refusal(client, urlopen):
|
|
277
|
+
urlopen((503, {"errorCode": "MERCHANT_API_UNAVAILABLE"}))
|
|
278
|
+
|
|
279
|
+
with pytest.raises(TransportError):
|
|
280
|
+
client.create_checkout_session(
|
|
281
|
+
amount=2500, currency="EUR", order_reference="order-1042"
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def test_network_failure_raises_transport(client, urlopen):
|
|
286
|
+
urlopen(urllib.error.URLError("connection reset"))
|
|
287
|
+
|
|
288
|
+
with pytest.raises(TransportError):
|
|
289
|
+
client.create_checkout_session(
|
|
290
|
+
amount=2500, currency="EUR", order_reference="order-1042"
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@pytest.mark.parametrize(
|
|
295
|
+
"code",
|
|
296
|
+
["INVALID_API_KEY", "INVALID_SIGNATURE", "TIMESTAMP_OUT_OF_RANGE", "IP_NOT_ALLOWED"],
|
|
297
|
+
)
|
|
298
|
+
def test_401_raises_authentication_with_the_error_code(client, urlopen, code):
|
|
299
|
+
urlopen((401, {"errorCode": code}))
|
|
300
|
+
|
|
301
|
+
with pytest.raises(AuthenticationError) as caught:
|
|
302
|
+
client.create_checkout_session(
|
|
303
|
+
amount=2500, currency="EUR", order_reference="order-1042"
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
assert caught.value.error_code == code
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def test_422_key_reuse_raises_api_error_not_transport(client, urlopen):
|
|
310
|
+
urlopen((422, {"errorCode": "IDEMPOTENCY_KEY_REUSED", "errorMessage": "Use a fresh key"}))
|
|
311
|
+
|
|
312
|
+
with pytest.raises(ApiError) as caught:
|
|
313
|
+
client.create_checkout_session(
|
|
314
|
+
amount=2500, currency="EUR", order_reference="order-1042"
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
assert caught.value.http_status == 422
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def test_non_json_response_raises_api_error(client, urlopen, monkeypatch):
|
|
321
|
+
class _Garbage(_Response):
|
|
322
|
+
def read(self):
|
|
323
|
+
return b"<html>502 Bad Gateway</html>"
|
|
324
|
+
|
|
325
|
+
monkeypatch.setattr(
|
|
326
|
+
urllib.request, "urlopen", lambda request, timeout=None: _Garbage(200, {})
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
with pytest.raises(ApiError):
|
|
330
|
+
client.create_checkout_session(
|
|
331
|
+
amount=2500, currency="EUR", order_reference="order-1042"
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def test_envelope_wrapped_response_is_unwrapped(client, urlopen):
|
|
336
|
+
urlopen((200, {"success": True, "data": {"success": True, "checkout": CHECKOUT}}))
|
|
337
|
+
|
|
338
|
+
session = client.create_checkout_session(
|
|
339
|
+
amount=2500, currency="EUR", order_reference="order-1042"
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
assert session == CHECKOUT
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
# --- retry with the same key -------------------------------------------------
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def test_retry_helper_reuses_the_same_idempotency_key(client, urlopen):
|
|
349
|
+
recorder = urlopen((503, {"errorCode": "MERCHANT_API_UNAVAILABLE"}), _ok())
|
|
350
|
+
|
|
351
|
+
session = client.create_checkout_session_with_retry(
|
|
352
|
+
amount=2500,
|
|
353
|
+
currency="EUR",
|
|
354
|
+
order_reference="order-1042",
|
|
355
|
+
max_attempts=2,
|
|
356
|
+
backoff_seconds=0,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
assert session == CHECKOUT
|
|
360
|
+
assert len(recorder.requests) == 2
|
|
361
|
+
keys = {_headers(r)["idempotency-key"] for r in recorder.requests}
|
|
362
|
+
assert len(keys) == 1, "a retry must not mint a new key - that is the double-charge bug"
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def test_retry_helper_honours_a_caller_supplied_key(client, urlopen):
|
|
366
|
+
recorder = urlopen((503, {}), _ok())
|
|
367
|
+
|
|
368
|
+
client.create_checkout_session_with_retry(
|
|
369
|
+
amount=2500,
|
|
370
|
+
currency="EUR",
|
|
371
|
+
order_reference="order-1042",
|
|
372
|
+
idempotency_key="my-own-key",
|
|
373
|
+
max_attempts=2,
|
|
374
|
+
backoff_seconds=0,
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
assert all(_headers(r)["idempotency-key"] == "my-own-key" for r in recorder.requests)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def test_retry_helper_does_not_retry_a_refusal(client, urlopen):
|
|
381
|
+
recorder = urlopen((200, {"success": False, "errorCode": "ALREADY_PROCESSED"}))
|
|
382
|
+
|
|
383
|
+
with pytest.raises(CheckoutRefusedError):
|
|
384
|
+
client.create_checkout_session_with_retry(
|
|
385
|
+
amount=2500,
|
|
386
|
+
currency="EUR",
|
|
387
|
+
order_reference="order-1042",
|
|
388
|
+
max_attempts=3,
|
|
389
|
+
backoff_seconds=0,
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
assert len(recorder.requests) == 1
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def test_retry_helper_does_not_retry_an_auth_failure(client, urlopen):
|
|
396
|
+
recorder = urlopen((401, {"errorCode": "INVALID_SIGNATURE"}))
|
|
397
|
+
|
|
398
|
+
with pytest.raises(AuthenticationError):
|
|
399
|
+
client.create_checkout_session_with_retry(
|
|
400
|
+
amount=2500,
|
|
401
|
+
currency="EUR",
|
|
402
|
+
order_reference="order-1042",
|
|
403
|
+
max_attempts=3,
|
|
404
|
+
backoff_seconds=0,
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
assert len(recorder.requests) == 1
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def test_retry_helper_gives_up_and_raises_the_transport_error(client, urlopen):
|
|
411
|
+
recorder = urlopen(urllib.error.URLError("down"))
|
|
412
|
+
|
|
413
|
+
with pytest.raises(TransportError):
|
|
414
|
+
client.create_checkout_session_with_retry(
|
|
415
|
+
amount=2500,
|
|
416
|
+
currency="EUR",
|
|
417
|
+
order_reference="order-1042",
|
|
418
|
+
max_attempts=3,
|
|
419
|
+
backoff_seconds=0,
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
assert len(recorder.requests) == 3
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Known-answer tests for the request signature.
|
|
2
|
+
|
|
3
|
+
The vector is the one published on the Website-integration tab and pinned in
|
|
4
|
+
web-platform's `SIGNING_TEST_VECTOR`. If these fail, the SDK cannot authenticate
|
|
5
|
+
against the gateway - fix the signing, never the expected value.
|
|
6
|
+
|
|
7
|
+
The secret below is a dummy from the public docs. It authenticates nothing.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
|
|
12
|
+
from dominaite import SESSIONS_PATH, sign_request
|
|
13
|
+
|
|
14
|
+
SECRET = "dms_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
|
15
|
+
TIMESTAMP = "1755302400"
|
|
16
|
+
IDEMPOTENCY_KEY = "00000000-0000-4000-8000-000000000001"
|
|
17
|
+
BODY = '{"amount":2500,"currency":"EUR","orderReference":"order-1042"}'
|
|
18
|
+
EXPECTED_BODY_SHA256 = "aa3edd72cd1829f4e053abb048b08c1ae91c2d67b08955997c4b6c4dab4f98ff"
|
|
19
|
+
EXPECTED_SIGNATURE = "95759958a0a0a9bd3e6e37101c01e8e7fee1166406e4ac2ff488764f5f742cbf"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_body_hash_matches_published_vector():
|
|
23
|
+
assert hashlib.sha256(BODY.encode("utf-8")).hexdigest() == EXPECTED_BODY_SHA256
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_post_signature_matches_published_vector():
|
|
27
|
+
signature = sign_request(
|
|
28
|
+
secret=SECRET,
|
|
29
|
+
timestamp=TIMESTAMP,
|
|
30
|
+
method="POST",
|
|
31
|
+
path=SESSIONS_PATH,
|
|
32
|
+
idempotency_key=IDEMPOTENCY_KEY,
|
|
33
|
+
body=BODY,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
assert signature == EXPECTED_SIGNATURE
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_signature_is_lowercase_hex():
|
|
40
|
+
signature = sign_request(SECRET, TIMESTAMP, "POST", SESSIONS_PATH, IDEMPOTENCY_KEY, BODY)
|
|
41
|
+
|
|
42
|
+
assert signature == signature.lower()
|
|
43
|
+
assert len(signature) == 64
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_method_is_uppercased_before_signing():
|
|
47
|
+
lower = sign_request(SECRET, TIMESTAMP, "post", SESSIONS_PATH, IDEMPOTENCY_KEY, BODY)
|
|
48
|
+
|
|
49
|
+
assert lower == EXPECTED_SIGNATURE
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_changing_the_idempotency_key_changes_the_signature():
|
|
53
|
+
"""The key is inside the signature, which is what makes replay harmless."""
|
|
54
|
+
other = sign_request(
|
|
55
|
+
SECRET,
|
|
56
|
+
TIMESTAMP,
|
|
57
|
+
"POST",
|
|
58
|
+
SESSIONS_PATH,
|
|
59
|
+
"00000000-0000-4000-8000-000000000002",
|
|
60
|
+
BODY,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
assert other != EXPECTED_SIGNATURE
|