autosignly 0.1.0.dev0__tar.gz → 0.1.2__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.
- {autosignly-0.1.0.dev0 → autosignly-0.1.2}/PKG-INFO +60 -4
- {autosignly-0.1.0.dev0 → autosignly-0.1.2}/README.md +59 -3
- {autosignly-0.1.0.dev0 → autosignly-0.1.2}/src/autosignly/__init__.py +12 -0
- autosignly-0.1.2/src/autosignly/_version.py +1 -0
- {autosignly-0.1.0.dev0 → autosignly-0.1.2}/src/autosignly/client.py +108 -10
- {autosignly-0.1.0.dev0 → autosignly-0.1.2}/src/autosignly/models.py +90 -0
- autosignly-0.1.2/src/autosignly/webhooks.py +79 -0
- {autosignly-0.1.0.dev0 → autosignly-0.1.2}/tests/test_client.py +162 -0
- autosignly-0.1.2/tests/test_contract.py +137 -0
- autosignly-0.1.2/tests/test_webhooks.py +74 -0
- autosignly-0.1.0.dev0/src/autosignly/_version.py +0 -1
- autosignly-0.1.0.dev0/src/autosignly/webhooks.py +0 -39
- autosignly-0.1.0.dev0/tests/test_webhooks.py +0 -40
- {autosignly-0.1.0.dev0 → autosignly-0.1.2}/.gitignore +0 -0
- {autosignly-0.1.0.dev0 → autosignly-0.1.2}/pyproject.toml +0 -0
- {autosignly-0.1.0.dev0 → autosignly-0.1.2}/src/autosignly/errors.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: autosignly
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: Python client for the Autosignly API - eIDAS electronic signatures and document workflows
|
|
5
5
|
Project-URL: Homepage, https://autosignly.eu
|
|
6
6
|
Project-URL: Documentation, https://docs.16it.eu/docs/intro/
|
|
@@ -69,6 +69,13 @@ for summary in client.iter_documents(status="SIGNED"):
|
|
|
69
69
|
print(summary.id, summary.name)
|
|
70
70
|
```
|
|
71
71
|
|
|
72
|
+
Both listing calls take `tag_id` as well. Several tags narrow the result — a document has to
|
|
73
|
+
carry all of them — and a tag that does not exist gives an empty page rather than an error:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
page = client.list_documents(tag_id=["contracts", "2026"], status="SIGNED")
|
|
77
|
+
```
|
|
78
|
+
|
|
72
79
|
## Downloading the file
|
|
73
80
|
|
|
74
81
|
A document carries a short-lived link to its file. The link expires, so fetch the document again
|
|
@@ -85,6 +92,44 @@ open("signed.pdf", "wb").write(pdf)
|
|
|
85
92
|
A document that is still being signed can be downloaded as well - it then carries only the
|
|
86
93
|
signatures collected so far.
|
|
87
94
|
|
|
95
|
+
## Attachments
|
|
96
|
+
|
|
97
|
+
Files attached to a document are converted to PDF and merged into it when it is sent for
|
|
98
|
+
signing, behind an index page listing each one with its checksum — so a single signature
|
|
99
|
+
covers the document and everything attached to it.
|
|
100
|
+
|
|
101
|
+
Attachments can only be added before the document is sent, so upload it first and send it
|
|
102
|
+
afterwards instead of using `upload_and_sign`:
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
document_id = client.upload_pdf(
|
|
106
|
+
pdf=open("protocol.pdf", "rb").read(),
|
|
107
|
+
document_name="Handover protocol",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
attachment = client.add_attachment(
|
|
111
|
+
document_id,
|
|
112
|
+
content=open("site-photo.jpg", "rb").read(),
|
|
113
|
+
file_name="site-photo.jpg",
|
|
114
|
+
)
|
|
115
|
+
print(attachment.order_index, attachment.sha256)
|
|
116
|
+
|
|
117
|
+
for existing in client.list_attachments(document_id):
|
|
118
|
+
print(existing.file_name, existing.page_count)
|
|
119
|
+
|
|
120
|
+
client.send_for_signing(document_id, signers=[signer])
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
An attachment can be dropped again while the document is still unsent:
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
client.delete_attachment(document_id, attachment.id)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
PDF, JPEG and PNG are accepted, recognised from the content rather than the file name.
|
|
130
|
+
Attachments merge in the order they were added, and can only be changed before the document
|
|
131
|
+
is sent for signing.
|
|
132
|
+
|
|
88
133
|
## Tags
|
|
89
134
|
|
|
90
135
|
```python
|
|
@@ -97,15 +142,26 @@ added to the company tag pool.
|
|
|
97
142
|
|
|
98
143
|
## Verifying webhooks
|
|
99
144
|
|
|
100
|
-
Autosignly signs every
|
|
101
|
-
|
|
145
|
+
Autosignly signs every delivery. Check the signature against the raw request body, before parsing
|
|
146
|
+
it - re-serialising the JSON changes the bytes and the signature will not match.
|
|
102
147
|
|
|
103
148
|
```python
|
|
104
149
|
from autosignly import webhooks
|
|
105
150
|
|
|
106
|
-
webhooks.verify(
|
|
151
|
+
webhooks.verify(
|
|
152
|
+
request.body,
|
|
153
|
+
request.headers["X-Webhook-Signature"],
|
|
154
|
+
webhook_key,
|
|
155
|
+
request.headers["X-Webhook-Timestamp"],
|
|
156
|
+
)
|
|
107
157
|
```
|
|
108
158
|
|
|
159
|
+
The signature covers the timestamp as well as the body, and a delivery older than five minutes is
|
|
160
|
+
rejected even when its signature matches, so a captured request cannot be replayed later.
|
|
161
|
+
|
|
162
|
+
While a webhook key is being rotated a delivery carries several signatures; it is accepted when any
|
|
163
|
+
of them matches, so rotation needs no change on your side.
|
|
164
|
+
|
|
109
165
|
`verify` raises `InvalidSignatureError` on a mismatch; `webhooks.is_valid(...)` returns a boolean
|
|
110
166
|
instead.
|
|
111
167
|
|
|
@@ -49,6 +49,13 @@ for summary in client.iter_documents(status="SIGNED"):
|
|
|
49
49
|
print(summary.id, summary.name)
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
+
Both listing calls take `tag_id` as well. Several tags narrow the result — a document has to
|
|
53
|
+
carry all of them — and a tag that does not exist gives an empty page rather than an error:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
page = client.list_documents(tag_id=["contracts", "2026"], status="SIGNED")
|
|
57
|
+
```
|
|
58
|
+
|
|
52
59
|
## Downloading the file
|
|
53
60
|
|
|
54
61
|
A document carries a short-lived link to its file. The link expires, so fetch the document again
|
|
@@ -65,6 +72,44 @@ open("signed.pdf", "wb").write(pdf)
|
|
|
65
72
|
A document that is still being signed can be downloaded as well - it then carries only the
|
|
66
73
|
signatures collected so far.
|
|
67
74
|
|
|
75
|
+
## Attachments
|
|
76
|
+
|
|
77
|
+
Files attached to a document are converted to PDF and merged into it when it is sent for
|
|
78
|
+
signing, behind an index page listing each one with its checksum — so a single signature
|
|
79
|
+
covers the document and everything attached to it.
|
|
80
|
+
|
|
81
|
+
Attachments can only be added before the document is sent, so upload it first and send it
|
|
82
|
+
afterwards instead of using `upload_and_sign`:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
document_id = client.upload_pdf(
|
|
86
|
+
pdf=open("protocol.pdf", "rb").read(),
|
|
87
|
+
document_name="Handover protocol",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
attachment = client.add_attachment(
|
|
91
|
+
document_id,
|
|
92
|
+
content=open("site-photo.jpg", "rb").read(),
|
|
93
|
+
file_name="site-photo.jpg",
|
|
94
|
+
)
|
|
95
|
+
print(attachment.order_index, attachment.sha256)
|
|
96
|
+
|
|
97
|
+
for existing in client.list_attachments(document_id):
|
|
98
|
+
print(existing.file_name, existing.page_count)
|
|
99
|
+
|
|
100
|
+
client.send_for_signing(document_id, signers=[signer])
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
An attachment can be dropped again while the document is still unsent:
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
client.delete_attachment(document_id, attachment.id)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
PDF, JPEG and PNG are accepted, recognised from the content rather than the file name.
|
|
110
|
+
Attachments merge in the order they were added, and can only be changed before the document
|
|
111
|
+
is sent for signing.
|
|
112
|
+
|
|
68
113
|
## Tags
|
|
69
114
|
|
|
70
115
|
```python
|
|
@@ -77,15 +122,26 @@ added to the company tag pool.
|
|
|
77
122
|
|
|
78
123
|
## Verifying webhooks
|
|
79
124
|
|
|
80
|
-
Autosignly signs every
|
|
81
|
-
|
|
125
|
+
Autosignly signs every delivery. Check the signature against the raw request body, before parsing
|
|
126
|
+
it - re-serialising the JSON changes the bytes and the signature will not match.
|
|
82
127
|
|
|
83
128
|
```python
|
|
84
129
|
from autosignly import webhooks
|
|
85
130
|
|
|
86
|
-
webhooks.verify(
|
|
131
|
+
webhooks.verify(
|
|
132
|
+
request.body,
|
|
133
|
+
request.headers["X-Webhook-Signature"],
|
|
134
|
+
webhook_key,
|
|
135
|
+
request.headers["X-Webhook-Timestamp"],
|
|
136
|
+
)
|
|
87
137
|
```
|
|
88
138
|
|
|
139
|
+
The signature covers the timestamp as well as the body, and a delivery older than five minutes is
|
|
140
|
+
rejected even when its signature matches, so a captured request cannot be replayed later.
|
|
141
|
+
|
|
142
|
+
While a webhook key is being rotated a delivery carries several signatures; it is accepted when any
|
|
143
|
+
of them matches, so rotation needs no change on your side.
|
|
144
|
+
|
|
89
145
|
`verify` raises `InvalidSignatureError` on a mismatch; `webhooks.is_valid(...)` returns a boolean
|
|
90
146
|
instead.
|
|
91
147
|
|
|
@@ -26,10 +26,16 @@ from .errors import (
|
|
|
26
26
|
ValidationError,
|
|
27
27
|
)
|
|
28
28
|
from .models import (
|
|
29
|
+
Attachment,
|
|
30
|
+
AttachmentFormat,
|
|
31
|
+
AttachmentStatus,
|
|
32
|
+
Credentials,
|
|
29
33
|
Document,
|
|
30
34
|
DocumentStatus,
|
|
35
|
+
EnvironmentType,
|
|
31
36
|
DocumentSummary,
|
|
32
37
|
Page,
|
|
38
|
+
SignatureMode,
|
|
33
39
|
SignatureType,
|
|
34
40
|
Signer,
|
|
35
41
|
SignerDetails,
|
|
@@ -53,10 +59,16 @@ __all__ = [
|
|
|
53
59
|
"RateLimitError",
|
|
54
60
|
"ServerError",
|
|
55
61
|
"ValidationError",
|
|
62
|
+
"Attachment",
|
|
63
|
+
"AttachmentFormat",
|
|
64
|
+
"AttachmentStatus",
|
|
65
|
+
"Credentials",
|
|
56
66
|
"Document",
|
|
57
67
|
"DocumentStatus",
|
|
68
|
+
"EnvironmentType",
|
|
58
69
|
"DocumentSummary",
|
|
59
70
|
"Page",
|
|
71
|
+
"SignatureMode",
|
|
60
72
|
"SignatureType",
|
|
61
73
|
"Signer",
|
|
62
74
|
"SignerDetails",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.2"
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
|
+
import mimetypes
|
|
6
7
|
import random
|
|
7
8
|
import time
|
|
8
9
|
import uuid
|
|
@@ -13,6 +14,8 @@ import httpx
|
|
|
13
14
|
from . import errors
|
|
14
15
|
from ._version import __version__
|
|
15
16
|
from .models import (
|
|
17
|
+
Attachment,
|
|
18
|
+
Credentials,
|
|
16
19
|
Document,
|
|
17
20
|
DocumentSummary,
|
|
18
21
|
Page,
|
|
@@ -87,23 +90,40 @@ class AutosignlyClient:
|
|
|
87
90
|
payload = self._request("GET", "/api-key")
|
|
88
91
|
return bool(payload.get("valid", False))
|
|
89
92
|
|
|
93
|
+
def describe_credentials(self) -> Credentials:
|
|
94
|
+
"""Report which company and environment this key and secret resolve to.
|
|
95
|
+
|
|
96
|
+
Useful before a first call: it says whether the pair points at
|
|
97
|
+
production or at a sandbox, without touching any document.
|
|
98
|
+
"""
|
|
99
|
+
payload = self._request("GET", "/credentials")
|
|
100
|
+
return Credentials.from_payload(payload)
|
|
101
|
+
|
|
90
102
|
# -- documents -----------------------------------------------------------
|
|
91
103
|
|
|
92
104
|
def list_documents(
|
|
93
105
|
self,
|
|
94
106
|
*,
|
|
95
107
|
status: str | Sequence[str] | None = None,
|
|
108
|
+
tag_id: str | Sequence[str] | None = None,
|
|
96
109
|
page: int = 0,
|
|
97
110
|
size: int = 20,
|
|
98
111
|
sort: str | None = None,
|
|
99
112
|
) -> Page[DocumentSummary]:
|
|
100
|
-
"""Return one page of documents belonging to this environment.
|
|
113
|
+
"""Return one page of documents belonging to this environment.
|
|
114
|
+
|
|
115
|
+
Several tags narrow the result: a document has to carry all of them. A
|
|
116
|
+
tag that does not exist yields an empty page rather than an error.
|
|
117
|
+
"""
|
|
101
118
|
params: list[tuple[str, Any]] = [("page", page), ("size", size)]
|
|
102
119
|
if sort:
|
|
103
120
|
params.append(("sort", sort))
|
|
104
121
|
if status:
|
|
105
122
|
values = [status] if isinstance(status, str) else list(status)
|
|
106
123
|
params.extend(("status", value) for value in values)
|
|
124
|
+
if tag_id:
|
|
125
|
+
tags = [tag_id] if isinstance(tag_id, str) else list(tag_id)
|
|
126
|
+
params.extend(("tagId", tag) for tag in tags)
|
|
107
127
|
|
|
108
128
|
payload = self._request("GET", "/documents", params=params)
|
|
109
129
|
return _to_page(payload, DocumentSummary.from_payload)
|
|
@@ -112,13 +132,15 @@ class AutosignlyClient:
|
|
|
112
132
|
self,
|
|
113
133
|
*,
|
|
114
134
|
status: str | Sequence[str] | None = None,
|
|
135
|
+
tag_id: str | Sequence[str] | None = None,
|
|
115
136
|
size: int = 50,
|
|
116
137
|
sort: str | None = None,
|
|
117
138
|
) -> Iterator[DocumentSummary]:
|
|
118
139
|
"""Walk every document, fetching further pages as needed."""
|
|
119
140
|
page_number = 0
|
|
120
141
|
while True:
|
|
121
|
-
page = self.list_documents(
|
|
142
|
+
page = self.list_documents(
|
|
143
|
+
status=status, tag_id=tag_id, page=page_number, size=size, sort=sort)
|
|
122
144
|
yield from page.content
|
|
123
145
|
if not page.has_next:
|
|
124
146
|
return
|
|
@@ -143,14 +165,68 @@ class AutosignlyClient:
|
|
|
143
165
|
status_code=404,
|
|
144
166
|
)
|
|
145
167
|
|
|
146
|
-
|
|
147
|
-
response = self._http.get(document.file_url)
|
|
148
|
-
except httpx.TransportError as exc:
|
|
149
|
-
raise errors.ConnectionError(f"Could not download {document_id}: {exc}") from exc
|
|
168
|
+
return self._download(document.file_url, document_id)
|
|
150
169
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
170
|
+
def upload_pdf(self, *, pdf: bytes, document_name: str, file_name: str = "document.pdf") -> str:
|
|
171
|
+
"""Store a PDF as a document without sending it to anyone.
|
|
172
|
+
|
|
173
|
+
Returns the identifier of the created document. Use this when the
|
|
174
|
+
document needs attachments before it goes out: upload it, attach the
|
|
175
|
+
files with :meth:`add_attachment`, then call :meth:`send_for_signing`.
|
|
176
|
+
A document that has already been sent can no longer take attachments.
|
|
177
|
+
"""
|
|
178
|
+
files = {
|
|
179
|
+
"file": (file_name, pdf, "application/pdf"),
|
|
180
|
+
"request": (None, json.dumps({"documentName": document_name}), "application/json"),
|
|
181
|
+
}
|
|
182
|
+
payload = self._request("POST", "/documents", files=files)
|
|
183
|
+
return payload.get("documentId", "")
|
|
184
|
+
|
|
185
|
+
# -- attachments ---------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
def list_attachments(self, document_id: str) -> list[Attachment]:
|
|
188
|
+
"""Return the attachments of a document, in the order they will merge."""
|
|
189
|
+
payload = self._request("GET", f"/documents/{document_id}/attachments")
|
|
190
|
+
return [Attachment.from_payload(item) for item in payload or []]
|
|
191
|
+
|
|
192
|
+
def add_attachment(
|
|
193
|
+
self,
|
|
194
|
+
document_id: str,
|
|
195
|
+
*,
|
|
196
|
+
content: bytes,
|
|
197
|
+
file_name: str,
|
|
198
|
+
) -> Attachment:
|
|
199
|
+
"""Attach a file to a document that has not been sent for signing yet.
|
|
200
|
+
|
|
201
|
+
The file is converted to PDF and merged into the document when it is
|
|
202
|
+
sent, behind an index page carrying its checksum, so one signature
|
|
203
|
+
covers the document and everything attached to it. PDF, JPEG and PNG
|
|
204
|
+
are accepted, recognised from the content rather than the file name.
|
|
205
|
+
Attachments merge in the order they were added.
|
|
206
|
+
"""
|
|
207
|
+
files = {"file": (file_name, content, _content_type(file_name))}
|
|
208
|
+
payload = self._request("POST", f"/documents/{document_id}/attachments", files=files)
|
|
209
|
+
return Attachment.from_payload(payload)
|
|
210
|
+
|
|
211
|
+
def delete_attachment(self, document_id: str, attachment_id: str) -> None:
|
|
212
|
+
"""Remove an attachment from a document not yet sent for signing."""
|
|
213
|
+
self._request("DELETE", f"/documents/{document_id}/attachments/{attachment_id}")
|
|
214
|
+
|
|
215
|
+
def download_attachment(self, document_id: str, attachment_id: str) -> bytes:
|
|
216
|
+
"""Fetch one attachment converted to PDF — the rendition that gets merged."""
|
|
217
|
+
for attachment in self.list_attachments(document_id):
|
|
218
|
+
if attachment.id != attachment_id:
|
|
219
|
+
continue
|
|
220
|
+
if not attachment.file_url:
|
|
221
|
+
raise errors.NotFoundError(
|
|
222
|
+
f"Attachment {attachment_id} is not converted yet",
|
|
223
|
+
status_code=404,
|
|
224
|
+
)
|
|
225
|
+
return self._download(attachment.file_url, attachment_id)
|
|
226
|
+
raise errors.NotFoundError(
|
|
227
|
+
f"Document {document_id} has no attachment {attachment_id}",
|
|
228
|
+
status_code=404,
|
|
229
|
+
)
|
|
154
230
|
|
|
155
231
|
def send_for_signing(
|
|
156
232
|
self,
|
|
@@ -158,6 +234,7 @@ class AutosignlyClient:
|
|
|
158
234
|
*,
|
|
159
235
|
signers: Sequence[Signer] | None = None,
|
|
160
236
|
signature_type: str | None = None,
|
|
237
|
+
signature_mode: str | None = None,
|
|
161
238
|
verification_method: str | None = None,
|
|
162
239
|
initiator_email: str | None = None,
|
|
163
240
|
initiator_locale: str | None = None,
|
|
@@ -173,6 +250,8 @@ class AutosignlyClient:
|
|
|
173
250
|
body["signers"] = [signer.to_payload() for signer in signers]
|
|
174
251
|
if signature_type:
|
|
175
252
|
body["signatureType"] = signature_type
|
|
253
|
+
if signature_mode:
|
|
254
|
+
body["signatureMode"] = signature_mode
|
|
176
255
|
if verification_method:
|
|
177
256
|
body["verificationMethod"] = verification_method
|
|
178
257
|
if initiator_email:
|
|
@@ -181,7 +260,7 @@ class AutosignlyClient:
|
|
|
181
260
|
"locale": initiator_locale,
|
|
182
261
|
}
|
|
183
262
|
|
|
184
|
-
payload = self._request("POST", f"/documents/{document_id}/
|
|
263
|
+
payload = self._request("POST", f"/documents/{document_id}/signings", json_body=body)
|
|
185
264
|
return SigningRequestResult.from_payload(payload)
|
|
186
265
|
|
|
187
266
|
def upload_and_sign(
|
|
@@ -191,6 +270,7 @@ class AutosignlyClient:
|
|
|
191
270
|
document_name: str,
|
|
192
271
|
signers: Sequence[Signer],
|
|
193
272
|
signature_type: str | None = None,
|
|
273
|
+
signature_mode: str | None = None,
|
|
194
274
|
verification_method: str | None = None,
|
|
195
275
|
initiator_email: str | None = None,
|
|
196
276
|
initiator_locale: str | None = None,
|
|
@@ -207,6 +287,8 @@ class AutosignlyClient:
|
|
|
207
287
|
}
|
|
208
288
|
if signature_type:
|
|
209
289
|
request["signatureType"] = signature_type
|
|
290
|
+
if signature_mode:
|
|
291
|
+
request["signatureMode"] = signature_mode
|
|
210
292
|
if verification_method:
|
|
211
293
|
request["verificationMethod"] = verification_method
|
|
212
294
|
if initiator_email:
|
|
@@ -266,6 +348,16 @@ class AutosignlyClient:
|
|
|
266
348
|
|
|
267
349
|
# -- transport -----------------------------------------------------------
|
|
268
350
|
|
|
351
|
+
def _download(self, url: str, subject: str) -> bytes:
|
|
352
|
+
try:
|
|
353
|
+
response = self._http.get(url)
|
|
354
|
+
except httpx.TransportError as exc:
|
|
355
|
+
raise errors.ConnectionError(f"Could not download {subject}: {exc}") from exc
|
|
356
|
+
|
|
357
|
+
if response.status_code >= 400:
|
|
358
|
+
raise _to_error(response)
|
|
359
|
+
return response.content
|
|
360
|
+
|
|
269
361
|
def _request(
|
|
270
362
|
self,
|
|
271
363
|
method: str,
|
|
@@ -312,6 +404,12 @@ class AutosignlyClient:
|
|
|
312
404
|
raise errors.ConnectionError(f"Could not reach {url}: {last_error}")
|
|
313
405
|
|
|
314
406
|
|
|
407
|
+
def _content_type(file_name: str) -> str:
|
|
408
|
+
"""The server detects the real format from the bytes; this is only a hint."""
|
|
409
|
+
guessed, _ = mimetypes.guess_type(file_name)
|
|
410
|
+
return guessed or "application/octet-stream"
|
|
411
|
+
|
|
412
|
+
|
|
315
413
|
def _backoff(attempt: int) -> float:
|
|
316
414
|
"""Exponential backoff with jitter.
|
|
317
415
|
|
|
@@ -30,6 +30,22 @@ class VerificationMethod:
|
|
|
30
30
|
BIOMETRIC = "BIOMETRIC"
|
|
31
31
|
|
|
32
32
|
|
|
33
|
+
class SignatureMode:
|
|
34
|
+
"""Where signatures are placed on the document."""
|
|
35
|
+
|
|
36
|
+
#: The signer places a visual stamp on the document.
|
|
37
|
+
STAMP = "STAMP"
|
|
38
|
+
#: Signatures are collected on a card appended to the document.
|
|
39
|
+
SIGNATURES_CARD = "SIGNATURES_CARD"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class EnvironmentType:
|
|
43
|
+
"""Which environment a key and secret pair belongs to."""
|
|
44
|
+
|
|
45
|
+
PROD = "PROD"
|
|
46
|
+
SANDBOX = "SANDBOX"
|
|
47
|
+
|
|
48
|
+
|
|
33
49
|
class SigningMode:
|
|
34
50
|
"""Whether a document still needs signatures."""
|
|
35
51
|
|
|
@@ -48,6 +64,23 @@ class DocumentStatus:
|
|
|
48
64
|
CANCELLED = "CANCELLED"
|
|
49
65
|
|
|
50
66
|
|
|
67
|
+
class AttachmentFormat:
|
|
68
|
+
"""Format of an attached file, detected from its content."""
|
|
69
|
+
|
|
70
|
+
PDF = "PDF"
|
|
71
|
+
JPEG = "JPEG"
|
|
72
|
+
PNG = "PNG"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class AttachmentStatus:
|
|
76
|
+
"""Whether an attachment is ready to be merged into the document."""
|
|
77
|
+
|
|
78
|
+
#: Converted to PDF and ready to be merged.
|
|
79
|
+
READY = "READY"
|
|
80
|
+
#: Conversion failed; the attachment is skipped when the document is signed.
|
|
81
|
+
FAILED = "FAILED"
|
|
82
|
+
|
|
83
|
+
|
|
51
84
|
class SigningStatus:
|
|
52
85
|
"""State of an individual signer within a signing request."""
|
|
53
86
|
|
|
@@ -55,6 +88,29 @@ class SigningStatus:
|
|
|
55
88
|
AWAITING_SIGNATURE = "AWAITING_SIGNATURE"
|
|
56
89
|
|
|
57
90
|
|
|
91
|
+
@dataclass(slots=True)
|
|
92
|
+
class Credentials:
|
|
93
|
+
"""Which company and environment a key and secret pair resolves to.
|
|
94
|
+
|
|
95
|
+
Every environment — production and each sandbox — has its own pair, so this
|
|
96
|
+
is how a caller confirms which data a key will touch before using it.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
valid: bool
|
|
100
|
+
company_id: str | None = None
|
|
101
|
+
environment_id: str | None = None
|
|
102
|
+
environment_type: str | None = None
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def from_payload(cls, payload: dict[str, Any]) -> "Credentials":
|
|
106
|
+
return cls(
|
|
107
|
+
valid=bool(payload.get("valid", False)),
|
|
108
|
+
company_id=payload.get("companyId"),
|
|
109
|
+
environment_id=payload.get("environmentId"),
|
|
110
|
+
environment_type=payload.get("environmentType"),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
58
114
|
@dataclass(slots=True)
|
|
59
115
|
class Signer:
|
|
60
116
|
"""A person asked to sign a document."""
|
|
@@ -145,6 +201,40 @@ class SignerDetails:
|
|
|
145
201
|
)
|
|
146
202
|
|
|
147
203
|
|
|
204
|
+
@dataclass(slots=True)
|
|
205
|
+
class Attachment:
|
|
206
|
+
"""A file attached to a document.
|
|
207
|
+
|
|
208
|
+
Attachments are converted to PDF and merged into the document when it is
|
|
209
|
+
sent for signing, behind an index page listing each one with its checksum,
|
|
210
|
+
so a single signature covers the document and everything attached to it.
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
id: str
|
|
214
|
+
order_index: int = 0
|
|
215
|
+
file_name: str | None = None
|
|
216
|
+
format: str | None = None
|
|
217
|
+
size_bytes: int = 0
|
|
218
|
+
sha256: str | None = None
|
|
219
|
+
page_count: int | None = None
|
|
220
|
+
status: str | None = None
|
|
221
|
+
file_url: str | None = None
|
|
222
|
+
|
|
223
|
+
@classmethod
|
|
224
|
+
def from_payload(cls, payload: dict[str, Any]) -> "Attachment":
|
|
225
|
+
return cls(
|
|
226
|
+
id=payload["id"],
|
|
227
|
+
order_index=payload.get("orderIndex", 0),
|
|
228
|
+
file_name=payload.get("fileName"),
|
|
229
|
+
format=payload.get("format"),
|
|
230
|
+
size_bytes=payload.get("sizeBytes", 0),
|
|
231
|
+
sha256=payload.get("sha256"),
|
|
232
|
+
page_count=payload.get("pageCount"),
|
|
233
|
+
status=payload.get("status"),
|
|
234
|
+
file_url=payload.get("fileUrl"),
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
148
238
|
@dataclass(slots=True)
|
|
149
239
|
class Document:
|
|
150
240
|
"""Full details of a document, including its signers and a link to its file.
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Verification of webhook deliveries.
|
|
2
|
+
|
|
3
|
+
Every delivery carries two headers: ``X-Webhook-Timestamp`` with the moment it
|
|
4
|
+
was signed, and ``X-Webhook-Signature`` with one or more signatures over
|
|
5
|
+
``timestamp + "." + body``. Several signatures appear while a webhook key is
|
|
6
|
+
being rotated; a delivery is genuine when any of them matches.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import hmac
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
from .errors import InvalidSignatureError
|
|
16
|
+
|
|
17
|
+
SIGNATURE_HEADER = "X-Webhook-Signature"
|
|
18
|
+
TIMESTAMP_HEADER = "X-Webhook-Timestamp"
|
|
19
|
+
|
|
20
|
+
SIGNATURE_VERSION = "v1"
|
|
21
|
+
DEFAULT_TOLERANCE_SECONDS = 300
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def compute_signature(payload: bytes, secret: str, timestamp: str) -> str:
|
|
25
|
+
"""Return the hex digest Autosignly sends for this payload and timestamp."""
|
|
26
|
+
signed_content = timestamp.encode("utf-8") + b"." + payload
|
|
27
|
+
return hmac.new(secret.encode("utf-8"), signed_content, hashlib.sha256).hexdigest()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def is_valid(
|
|
31
|
+
payload: bytes,
|
|
32
|
+
signature_header: str,
|
|
33
|
+
secret: str,
|
|
34
|
+
timestamp: str,
|
|
35
|
+
*,
|
|
36
|
+
tolerance: int = DEFAULT_TOLERANCE_SECONDS,
|
|
37
|
+
) -> bool:
|
|
38
|
+
"""Check a delivery without raising.
|
|
39
|
+
|
|
40
|
+
``payload`` must be the raw request body exactly as received. Parsing and
|
|
41
|
+
re-serialising the JSON changes the bytes and invalidates the signature.
|
|
42
|
+
|
|
43
|
+
A delivery older than ``tolerance`` seconds is rejected even when its
|
|
44
|
+
signature matches, so a captured request cannot be replayed later. Pass
|
|
45
|
+
``tolerance=0`` to skip that check.
|
|
46
|
+
"""
|
|
47
|
+
if not signature_header or not secret or not timestamp:
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
if tolerance and not _is_fresh(timestamp, tolerance):
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
expected = compute_signature(payload, secret, timestamp)
|
|
54
|
+
for candidate in signature_header.split(","):
|
|
55
|
+
version, _, digest = candidate.strip().partition("=")
|
|
56
|
+
if version == SIGNATURE_VERSION and digest and hmac.compare_digest(digest, expected):
|
|
57
|
+
return True
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def verify(
|
|
62
|
+
payload: bytes,
|
|
63
|
+
signature_header: str,
|
|
64
|
+
secret: str,
|
|
65
|
+
timestamp: str,
|
|
66
|
+
*,
|
|
67
|
+
tolerance: int = DEFAULT_TOLERANCE_SECONDS,
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Check a delivery and raise :class:`InvalidSignatureError` if it fails."""
|
|
70
|
+
if not is_valid(payload, signature_header, secret, timestamp, tolerance=tolerance):
|
|
71
|
+
raise InvalidSignatureError("Webhook signature does not match the payload")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _is_fresh(timestamp: str, tolerance: int) -> bool:
|
|
75
|
+
try:
|
|
76
|
+
sent_at = int(timestamp.strip())
|
|
77
|
+
except ValueError:
|
|
78
|
+
return False
|
|
79
|
+
return abs(time.time() - sent_at) <= tolerance
|
|
@@ -28,6 +28,30 @@ def build_client(handler, **kwargs):
|
|
|
28
28
|
)
|
|
29
29
|
|
|
30
30
|
|
|
31
|
+
def test_describe_credentials_reports_the_environment():
|
|
32
|
+
seen = {}
|
|
33
|
+
|
|
34
|
+
def handler(request):
|
|
35
|
+
seen["url"] = str(request.url)
|
|
36
|
+
return httpx.Response(
|
|
37
|
+
200,
|
|
38
|
+
json={
|
|
39
|
+
"valid": True,
|
|
40
|
+
"companyId": "co-1",
|
|
41
|
+
"environmentId": "env-1",
|
|
42
|
+
"environmentType": "SANDBOX",
|
|
43
|
+
},
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
with build_client(handler) as client:
|
|
47
|
+
credentials = client.describe_credentials()
|
|
48
|
+
|
|
49
|
+
assert seen["url"] == "https://api.test/api/publics/v1/credentials"
|
|
50
|
+
assert credentials.valid is True
|
|
51
|
+
assert credentials.company_id == "co-1"
|
|
52
|
+
assert credentials.environment_type == "SANDBOX"
|
|
53
|
+
|
|
54
|
+
|
|
31
55
|
def test_sends_credentials_as_headers():
|
|
32
56
|
seen = {}
|
|
33
57
|
|
|
@@ -79,6 +103,34 @@ def test_list_documents_parses_page_and_sends_filters():
|
|
|
79
103
|
assert ("status", "GENERATED") in seen["params"]
|
|
80
104
|
|
|
81
105
|
|
|
106
|
+
def test_list_documents_repeats_the_tag_filter():
|
|
107
|
+
seen = {}
|
|
108
|
+
|
|
109
|
+
def handler(request):
|
|
110
|
+
seen["url"] = str(request.url)
|
|
111
|
+
return httpx.Response(200, json={"content": [], "page": {"number": 0, "size": 20, "totalElements": 0, "totalPages": 0}})
|
|
112
|
+
|
|
113
|
+
with build_client(handler) as client:
|
|
114
|
+
client.list_documents(tag_id=["tag-1", "tag-2"], status="SIGNED")
|
|
115
|
+
|
|
116
|
+
assert "tagId=tag-1" in seen["url"]
|
|
117
|
+
assert "tagId=tag-2" in seen["url"]
|
|
118
|
+
assert "status=SIGNED" in seen["url"]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_list_documents_accepts_a_single_tag():
|
|
122
|
+
seen = {}
|
|
123
|
+
|
|
124
|
+
def handler(request):
|
|
125
|
+
seen["url"] = str(request.url)
|
|
126
|
+
return httpx.Response(200, json={"content": [], "page": {"number": 0, "size": 20, "totalElements": 0, "totalPages": 0}})
|
|
127
|
+
|
|
128
|
+
with build_client(handler) as client:
|
|
129
|
+
client.list_documents(tag_id="tag-1")
|
|
130
|
+
|
|
131
|
+
assert seen["url"].count("tagId=") == 1
|
|
132
|
+
|
|
133
|
+
|
|
82
134
|
def test_iter_documents_follows_pages():
|
|
83
135
|
pages = {
|
|
84
136
|
"0": {
|
|
@@ -148,6 +200,116 @@ def test_upload_and_sign_posts_multipart_with_json_part():
|
|
|
148
200
|
assert seen["idempotency"]
|
|
149
201
|
|
|
150
202
|
|
|
203
|
+
def test_upload_pdf_stores_the_document_without_sending_it():
|
|
204
|
+
seen = {}
|
|
205
|
+
|
|
206
|
+
def handler(request):
|
|
207
|
+
seen["url"] = str(request.url)
|
|
208
|
+
seen["body"] = request.content
|
|
209
|
+
return httpx.Response(200, json={"documentId": "doc-7"})
|
|
210
|
+
|
|
211
|
+
with build_client(handler) as client:
|
|
212
|
+
document_id = client.upload_pdf(pdf=b"%PDF-1.4 fake", document_name="Umowa", file_name="umowa.pdf")
|
|
213
|
+
|
|
214
|
+
assert document_id == "doc-7"
|
|
215
|
+
assert seen["url"].endswith("/documents")
|
|
216
|
+
assert b'name="request"' in seen["body"]
|
|
217
|
+
assert b"Umowa" in seen["body"]
|
|
218
|
+
assert b"signers" not in seen["body"]
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def test_list_attachments_parses_the_merge_order():
|
|
222
|
+
payload = [
|
|
223
|
+
{
|
|
224
|
+
"id": "att-1",
|
|
225
|
+
"orderIndex": 0,
|
|
226
|
+
"fileName": "photo.jpg",
|
|
227
|
+
"format": "JPEG",
|
|
228
|
+
"sizeBytes": 482913,
|
|
229
|
+
"sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
|
|
230
|
+
"pageCount": 1,
|
|
231
|
+
"status": "READY",
|
|
232
|
+
"fileUrl": "https://files.example/att-1.pdf",
|
|
233
|
+
},
|
|
234
|
+
{"id": "att-2", "orderIndex": 1, "fileName": "annex.pdf", "format": "PDF", "status": "READY"},
|
|
235
|
+
]
|
|
236
|
+
seen = {}
|
|
237
|
+
|
|
238
|
+
def handler(request):
|
|
239
|
+
seen["url"] = str(request.url)
|
|
240
|
+
return httpx.Response(200, json=payload)
|
|
241
|
+
|
|
242
|
+
with build_client(handler) as client:
|
|
243
|
+
attachments = client.list_attachments("doc-1")
|
|
244
|
+
|
|
245
|
+
assert seen["url"].endswith("/documents/doc-1/attachments")
|
|
246
|
+
assert [a.id for a in attachments] == ["att-1", "att-2"]
|
|
247
|
+
assert [a.order_index for a in attachments] == [0, 1]
|
|
248
|
+
assert attachments[0].file_name == "photo.jpg"
|
|
249
|
+
assert attachments[0].page_count == 1
|
|
250
|
+
assert attachments[0].sha256.startswith("9f86d081")
|
|
251
|
+
assert attachments[1].page_count is None
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def test_add_attachment_posts_the_file_as_multipart():
|
|
255
|
+
seen = {}
|
|
256
|
+
|
|
257
|
+
def handler(request):
|
|
258
|
+
seen["method"] = request.method
|
|
259
|
+
seen["url"] = str(request.url)
|
|
260
|
+
seen["content_type"] = request.headers.get("content-type", "")
|
|
261
|
+
seen["body"] = request.content
|
|
262
|
+
return httpx.Response(200, json={"id": "att-1", "orderIndex": 0, "fileName": "photo.jpg"})
|
|
263
|
+
|
|
264
|
+
with build_client(handler) as client:
|
|
265
|
+
attachment = client.add_attachment("doc-1", content=b"\xff\xd8\xff fake jpeg", file_name="photo.jpg")
|
|
266
|
+
|
|
267
|
+
assert attachment.id == "att-1"
|
|
268
|
+
assert seen["method"] == "POST"
|
|
269
|
+
assert seen["url"].endswith("/documents/doc-1/attachments")
|
|
270
|
+
assert seen["content_type"].startswith("multipart/form-data")
|
|
271
|
+
assert b'name="file"' in seen["body"]
|
|
272
|
+
assert b"image/jpeg" in seen["body"]
|
|
273
|
+
assert b"fake jpeg" in seen["body"]
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def test_delete_attachment_targets_the_attachment():
|
|
277
|
+
seen = {}
|
|
278
|
+
|
|
279
|
+
def handler(request):
|
|
280
|
+
seen["method"] = request.method
|
|
281
|
+
seen["url"] = str(request.url)
|
|
282
|
+
return httpx.Response(204)
|
|
283
|
+
|
|
284
|
+
with build_client(handler) as client:
|
|
285
|
+
client.delete_attachment("doc-1", "att-1")
|
|
286
|
+
|
|
287
|
+
assert seen["method"] == "DELETE"
|
|
288
|
+
assert seen["url"].endswith("/documents/doc-1/attachments/att-1")
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def test_download_attachment_follows_the_converted_file_url():
|
|
292
|
+
def handler(request):
|
|
293
|
+
if request.url.path.endswith("/attachments"):
|
|
294
|
+
return httpx.Response(
|
|
295
|
+
200,
|
|
296
|
+
json=[{"id": "att-1", "fileUrl": "https://files.example/att-1.pdf"}],
|
|
297
|
+
)
|
|
298
|
+
return httpx.Response(200, content=b"%PDF converted")
|
|
299
|
+
|
|
300
|
+
with build_client(handler) as client:
|
|
301
|
+
assert client.download_attachment("doc-1", "att-1") == b"%PDF converted"
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def test_download_attachment_before_conversion_raises():
|
|
305
|
+
def handler(request):
|
|
306
|
+
return httpx.Response(200, json=[{"id": "att-1", "status": "FAILED"}])
|
|
307
|
+
|
|
308
|
+
with build_client(handler) as client:
|
|
309
|
+
with pytest.raises(NotFoundError):
|
|
310
|
+
client.download_attachment("doc-1", "att-1")
|
|
311
|
+
|
|
312
|
+
|
|
151
313
|
def test_signer_payload_omits_empty_fields():
|
|
152
314
|
payload = Signer(
|
|
153
315
|
first_name="Anna", last_name="Nowak", email="anna@example.com", country="PL"
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Contract test: every field name this client reads or sends must exist in the
|
|
2
|
+
published OpenAPI schema.
|
|
3
|
+
|
|
4
|
+
The field lists are not written by hand. Each parser is called with a mapping
|
|
5
|
+
that records which keys it asks for, so a parser that starts reading a different
|
|
6
|
+
key is checked automatically — which a hand-kept list would never do.
|
|
7
|
+
|
|
8
|
+
Runs offline against ``spec/autodocuments-v1.yaml``: no environment, no network.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Callable
|
|
15
|
+
|
|
16
|
+
import pytest
|
|
17
|
+
import yaml
|
|
18
|
+
|
|
19
|
+
from autosignly.client import _to_page
|
|
20
|
+
from autosignly.models import (
|
|
21
|
+
Attachment,
|
|
22
|
+
Credentials,
|
|
23
|
+
Document,
|
|
24
|
+
DocumentSummary,
|
|
25
|
+
Signer,
|
|
26
|
+
SignerDetails,
|
|
27
|
+
SignerStatus,
|
|
28
|
+
SigningRequestResult,
|
|
29
|
+
Tag,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
SPEC = yaml.safe_load(
|
|
33
|
+
(Path(__file__).resolve().parents[3] / "spec" / "autodocuments-v1.yaml").read_text()
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def properties_of(schema: str) -> set[str]:
|
|
38
|
+
found = SPEC["components"]["schemas"].get(schema)
|
|
39
|
+
assert found is not None, f"schema {schema} is missing from the spec"
|
|
40
|
+
return set(found.get("properties") or {})
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Recorder(dict):
|
|
44
|
+
"""A payload that remembers every key asked of it."""
|
|
45
|
+
|
|
46
|
+
def __init__(self) -> None:
|
|
47
|
+
super().__init__()
|
|
48
|
+
self.seen: set[str] = set()
|
|
49
|
+
|
|
50
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
51
|
+
self.seen.add(key)
|
|
52
|
+
return default
|
|
53
|
+
|
|
54
|
+
def __getitem__(self, key: str) -> Any:
|
|
55
|
+
self.seen.add(key)
|
|
56
|
+
raise KeyError(key)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def keys_read_by(parse: Callable[[dict], Any]) -> set[str]:
|
|
60
|
+
recorder = Recorder()
|
|
61
|
+
try:
|
|
62
|
+
parse(recorder)
|
|
63
|
+
except KeyError:
|
|
64
|
+
pass
|
|
65
|
+
return recorder.seen
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
PARSERS = [
|
|
69
|
+
("Attachment", "AttachmentResponse", Attachment.from_payload),
|
|
70
|
+
("Document", "DocumentInfoResponse", Document.from_payload),
|
|
71
|
+
("DocumentSummary", "DocumentListItemResponse", DocumentSummary.from_payload),
|
|
72
|
+
("SignerDetails", "SignerResponse", SignerDetails.from_payload),
|
|
73
|
+
("SignerStatus", "SignerStatusResponse", SignerStatus.from_payload),
|
|
74
|
+
("Credentials", "CredentialsResponse", Credentials.from_payload),
|
|
75
|
+
("SigningRequestResult", "SendForSigningResponse", SigningRequestResult.from_payload),
|
|
76
|
+
("Tag", "TagResponse", Tag.from_payload),
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@pytest.mark.parametrize(("name", "schema", "parse"), PARSERS, ids=[p[0] for p in PARSERS])
|
|
81
|
+
def test_parser_only_reads_declared_fields(name: str, schema: str, parse) -> None:
|
|
82
|
+
unknown = sorted(keys_read_by(parse) - properties_of(schema))
|
|
83
|
+
assert unknown == [], f"{name} reads fields the API does not send: {', '.join(unknown)}"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_page_reads_the_envelope_the_api_sends() -> None:
|
|
87
|
+
envelope = properties_of("PageResponseDocumentListItemResponseV1")
|
|
88
|
+
unknown = sorted(keys_read_by(lambda payload: _to_page(payload, lambda item: None)) - envelope)
|
|
89
|
+
assert unknown == []
|
|
90
|
+
|
|
91
|
+
info = properties_of("PageInfo")
|
|
92
|
+
for counter in ("number", "size", "totalElements", "totalPages"):
|
|
93
|
+
assert counter in info, f"PageInfo lost {counter}"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_signer_sends_only_fields_the_api_accepts() -> None:
|
|
97
|
+
payload = Signer(
|
|
98
|
+
first_name="Anna",
|
|
99
|
+
last_name="Nowak",
|
|
100
|
+
email="anna@example.com",
|
|
101
|
+
country="PL",
|
|
102
|
+
phone_number="+48123456789",
|
|
103
|
+
locale="pl",
|
|
104
|
+
order=1,
|
|
105
|
+
signature_type="AES",
|
|
106
|
+
signature_verification_method="SMS",
|
|
107
|
+
).to_payload()
|
|
108
|
+
|
|
109
|
+
unknown = sorted(set(payload) - properties_of("ExternalSignerRequest"))
|
|
110
|
+
assert unknown == [], f"unknown signer fields: {', '.join(unknown)}"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_document_filters_the_client_sends_exist() -> None:
|
|
114
|
+
declared = {
|
|
115
|
+
param["name"]
|
|
116
|
+
for param in SPEC["paths"]["/api/publics/v1/documents"]["get"]["parameters"]
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
assert {"status", "tagId", "page", "size", "sort"} <= declared
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_every_endpoint_the_client_calls_exists() -> None:
|
|
123
|
+
called = {
|
|
124
|
+
"/api/publics/v1/api-key",
|
|
125
|
+
"/api/publics/v1/credentials",
|
|
126
|
+
"/api/publics/v1/documents",
|
|
127
|
+
"/api/publics/v1/documents/{documentId}",
|
|
128
|
+
"/api/publics/v1/documents/{documentId}/attachments",
|
|
129
|
+
"/api/publics/v1/documents/{documentId}/attachments/{attachmentId}",
|
|
130
|
+
"/api/publics/v1/documents/signings",
|
|
131
|
+
"/api/publics/v1/documents/{documentId}/signings",
|
|
132
|
+
"/api/publics/v1/documents/{documentId}/tags",
|
|
133
|
+
"/api/publics/v1/tags",
|
|
134
|
+
"/api/publics/v1/tags/{tagId}",
|
|
135
|
+
}
|
|
136
|
+
missing = sorted(called - set(SPEC["paths"]))
|
|
137
|
+
assert missing == [], f"endpoints gone from the API: {', '.join(missing)}"
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import hmac
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from autosignly import InvalidSignatureError
|
|
8
|
+
from autosignly import webhooks
|
|
9
|
+
|
|
10
|
+
SECRET = "wh_secret"
|
|
11
|
+
PAYLOAD = b'{"eventId":"1","eventType":"document.signed"}'
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def now():
|
|
15
|
+
return str(int(time.time()))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def signed(payload=PAYLOAD, secret=SECRET, timestamp=None):
|
|
19
|
+
timestamp = timestamp or now()
|
|
20
|
+
content = timestamp.encode() + b"." + payload
|
|
21
|
+
return hmac.new(secret.encode(), content, hashlib.sha256).hexdigest()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_accepts_a_matching_signature():
|
|
25
|
+
ts = now()
|
|
26
|
+
assert webhooks.is_valid(PAYLOAD, f"v1={signed(timestamp=ts)}", SECRET, ts) is True
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_signature_covers_the_timestamp():
|
|
30
|
+
ts = now()
|
|
31
|
+
other = str(int(ts) - 1)
|
|
32
|
+
assert webhooks.is_valid(PAYLOAD, f"v1={signed(timestamp=other)}", SECRET, ts) is False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_rejects_a_signature_for_different_bytes():
|
|
36
|
+
ts = now()
|
|
37
|
+
assert webhooks.is_valid(b'{"eventId":"2"}', f"v1={signed(timestamp=ts)}", SECRET, ts) is False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_rejects_a_signature_made_with_another_secret():
|
|
41
|
+
ts = now()
|
|
42
|
+
assert webhooks.is_valid(PAYLOAD, f"v1={signed(secret='other', timestamp=ts)}", SECRET, ts) is False
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_accepts_when_one_of_several_signatures_matches():
|
|
46
|
+
ts = now()
|
|
47
|
+
header = f"v1=deadbeef,v1={signed(timestamp=ts)}"
|
|
48
|
+
assert webhooks.is_valid(PAYLOAD, header, SECRET, ts) is True
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_rejects_an_old_delivery_even_with_a_valid_signature():
|
|
52
|
+
ts = str(int(time.time()) - 3600)
|
|
53
|
+
assert webhooks.is_valid(PAYLOAD, f"v1={signed(timestamp=ts)}", SECRET, ts) is False
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_tolerance_can_be_disabled():
|
|
57
|
+
ts = str(int(time.time()) - 3600)
|
|
58
|
+
assert webhooks.is_valid(PAYLOAD, f"v1={signed(timestamp=ts)}", SECRET, ts, tolerance=0) is True
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_rejects_an_unknown_signature_version():
|
|
62
|
+
ts = now()
|
|
63
|
+
assert webhooks.is_valid(PAYLOAD, f"v2={signed(timestamp=ts)}", SECRET, ts) is False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_rejects_missing_header_or_timestamp():
|
|
67
|
+
ts = now()
|
|
68
|
+
assert webhooks.is_valid(PAYLOAD, "", SECRET, ts) is False
|
|
69
|
+
assert webhooks.is_valid(PAYLOAD, f"v1={signed(timestamp=ts)}", SECRET, "") is False
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_verify_raises_on_mismatch():
|
|
73
|
+
with pytest.raises(InvalidSignatureError):
|
|
74
|
+
webhooks.verify(PAYLOAD, "v1=deadbeef", SECRET, now())
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.1.0.dev0"
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
"""Verification of webhook deliveries."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import hashlib
|
|
6
|
-
import hmac
|
|
7
|
-
|
|
8
|
-
from .errors import InvalidSignatureError
|
|
9
|
-
|
|
10
|
-
SIGNATURE_HEADER = "X-Webhook-Signature"
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
def compute_signature(payload: bytes, secret: str) -> str:
|
|
14
|
-
"""Return the hex digest Autosignly sends for this payload."""
|
|
15
|
-
return hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).hexdigest()
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def is_valid(payload: bytes, signature_header: str, secret: str) -> bool:
|
|
19
|
-
"""Check a delivery without raising.
|
|
20
|
-
|
|
21
|
-
``payload`` must be the raw request body exactly as received. Parsing and
|
|
22
|
-
re-serialising the JSON changes the bytes and invalidates the signature.
|
|
23
|
-
"""
|
|
24
|
-
if not signature_header or not secret:
|
|
25
|
-
return False
|
|
26
|
-
|
|
27
|
-
expected = compute_signature(payload, secret)
|
|
28
|
-
for candidate in signature_header.split(","):
|
|
29
|
-
candidate = candidate.strip()
|
|
30
|
-
_, _, digest = candidate.rpartition("=")
|
|
31
|
-
if digest and hmac.compare_digest(digest, expected):
|
|
32
|
-
return True
|
|
33
|
-
return False
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def verify(payload: bytes, signature_header: str, secret: str) -> None:
|
|
37
|
-
"""Check a delivery and raise :class:`InvalidSignatureError` if it fails."""
|
|
38
|
-
if not is_valid(payload, signature_header, secret):
|
|
39
|
-
raise InvalidSignatureError("Webhook signature does not match the payload")
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
import hashlib
|
|
2
|
-
import hmac
|
|
3
|
-
|
|
4
|
-
import pytest
|
|
5
|
-
|
|
6
|
-
from autosignly import InvalidSignatureError
|
|
7
|
-
from autosignly import webhooks
|
|
8
|
-
|
|
9
|
-
SECRET = "wh_secret"
|
|
10
|
-
PAYLOAD = b'{"eventId":"1","eventType":"document.signed"}'
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
def signed(payload=PAYLOAD, secret=SECRET):
|
|
14
|
-
return hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def test_accepts_a_matching_signature():
|
|
18
|
-
assert webhooks.is_valid(PAYLOAD, f"sha256={signed()}", SECRET) is True
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def test_rejects_a_signature_for_different_bytes():
|
|
22
|
-
assert webhooks.is_valid(b'{"eventId":"2"}', f"sha256={signed()}", SECRET) is False
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def test_rejects_a_signature_made_with_another_secret():
|
|
26
|
-
assert webhooks.is_valid(PAYLOAD, f"sha256={signed(secret='other')}", SECRET) is False
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def test_accepts_when_one_of_several_signatures_matches():
|
|
30
|
-
header = f"sha256=deadbeef,sha256={signed()}"
|
|
31
|
-
assert webhooks.is_valid(PAYLOAD, header, SECRET) is True
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def test_rejects_missing_header():
|
|
35
|
-
assert webhooks.is_valid(PAYLOAD, "", SECRET) is False
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def test_verify_raises_on_mismatch():
|
|
39
|
-
with pytest.raises(InvalidSignatureError):
|
|
40
|
-
webhooks.verify(PAYLOAD, "sha256=deadbeef", SECRET)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|