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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: autosignly
3
- Version: 0.1.0.dev0
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 webhook delivery. Check the signature against the raw request body, before
101
- parsing it - re-serialising the JSON changes the bytes and the signature will not match.
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(request.body, request.headers["X-Webhook-Signature"], webhook_key)
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 webhook delivery. Check the signature against the raw request body, before
81
- parsing it - re-serialising the JSON changes the bytes and the signature will not match.
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(request.body, request.headers["X-Webhook-Signature"], webhook_key)
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(status=status, page=page_number, size=size, sort=sort)
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
- try:
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
- if response.status_code >= 400:
152
- raise _to_error(response)
153
- return response.content
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}/send-for-signing", json_body=body)
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