feat(errors): add redacted typed failures
This commit is contained in:
8 files changed
+348
-26
No files matched your search
@@ -48,6 +48,37 @@ If the cert/key are minted at runtime and never written to disk (e.g. inside an
|
|||||||
sess = DtlsCoapSession("192.168.1.100", 49154, cert_pem=cert_pem, key_pem=key_pem)
|
sess = DtlsCoapSession("192.168.1.100", 49154, cert_pem=cert_pem, key_pem=key_pem)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Classified errors
|
||||||
|
|
||||||
|
Runtime transport failures use the public types in
|
||||||
|
|
||||||
|
```python
|
||||||
|
from smartthings_local.errors import SessionClosedError, SmartThingsLocalError
|
||||||
|
```
|
||||||
|
|
||||||
|
All classified errors inherit from `SmartThingsLocalError` and expose a stable
|
||||||
|
`code`. Their messages are fixed and deliberately omit remote endpoints, local
|
||||||
|
paths, credential metadata, raw packets, and backend exception text. Existing
|
||||||
|
callers can keep catching the built-in types used by earlier releases:
|
||||||
|
|
||||||
|
| Error | Stable code | Compatible built-in |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `EndpointError` | `endpoint` | `OSError` |
|
||||||
|
| `ProbeError` | `probe` | `ConnectionError` |
|
||||||
|
| `SessionError` | `session` | `ConnectionError` |
|
||||||
|
| `AuthenticationError` | `authentication` | `ConnectionError` |
|
||||||
|
| `AuthorizationError` | `authorization` | `PermissionError` |
|
||||||
|
| `SessionTimeoutError` | `timeout` | `TimeoutError` |
|
||||||
|
| `SessionClosedError` | `session_closed` | `ConnectionError` |
|
||||||
|
| `MalformedMessageError` | `malformed_message` | `ValueError` |
|
||||||
|
| `BlockwiseError` | `blockwise` | `ConnectionError` |
|
||||||
|
| `ObserveError` | `observe` | `ConnectionError` |
|
||||||
|
|
||||||
|
Constructor argument validation remains a normal `ValueError`. When a backend
|
||||||
|
failure is chained for debugging, the cause is replaced with a fixed redacted
|
||||||
|
marker; raw backend text is not copied into the public error or its formatted
|
||||||
|
traceback.
|
||||||
|
|
||||||
For a full worked integration, the higher-level `smartthings_local.ocf` layer (`StateCache`, `PollScheduler`, `KeepaliveTask`, `ObserveRefreshTask`) coordinates tiered polling and OBSERVE on top of a session. The MQTT bridge demo below wires all of it together.
|
For a full worked integration, the higher-level `smartthings_local.ocf` layer (`StateCache`, `PollScheduler`, `KeepaliveTask`, `ObserveRefreshTask`) coordinates tiered polling and OBSERVE on top of a session. The MQTT bridge demo below wires all of it together.
|
||||||
|
|
||||||
### What the demo bridge gives you
|
### What the demo bridge gives you
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""Public, redacted exception types for smartthings-local."""
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'AuthenticationError',
|
||||||
|
'AuthorizationError',
|
||||||
|
'BlockwiseError',
|
||||||
|
'EndpointError',
|
||||||
|
'MalformedMessageError',
|
||||||
|
'ObserveError',
|
||||||
|
'ProbeError',
|
||||||
|
'SessionClosedError',
|
||||||
|
'SessionError',
|
||||||
|
'SessionTimeoutError',
|
||||||
|
'SmartThingsLocalError',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class SmartThingsLocalError(Exception):
|
||||||
|
"""Base class for classified library failures.
|
||||||
|
|
||||||
|
Subclasses expose a stable ``code`` and a fixed, non-sensitive message.
|
||||||
|
They intentionally do not accept arbitrary detail because backend errors
|
||||||
|
can contain remote endpoints, local paths, or credential metadata.
|
||||||
|
"""
|
||||||
|
|
||||||
|
code = 'smartthings_local_error'
|
||||||
|
message = 'SmartThings Local operation failed'
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(self.message)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'{type(self).__name__}(code={self.code!r})'
|
||||||
|
|
||||||
|
|
||||||
|
class EndpointError(SmartThingsLocalError, OSError):
|
||||||
|
"""An endpoint could not be resolved, bound, or connected."""
|
||||||
|
|
||||||
|
code = 'endpoint'
|
||||||
|
message = 'endpoint operation failed'
|
||||||
|
|
||||||
|
|
||||||
|
class ProbeError(SmartThingsLocalError, ConnectionError):
|
||||||
|
"""A DTLS probe failed before producing a protocol result."""
|
||||||
|
|
||||||
|
code = 'probe'
|
||||||
|
message = 'DTLS probe failed'
|
||||||
|
|
||||||
|
|
||||||
|
class SessionError(SmartThingsLocalError, ConnectionError):
|
||||||
|
"""A connected-session operation failed."""
|
||||||
|
|
||||||
|
code = 'session'
|
||||||
|
message = 'session operation failed'
|
||||||
|
|
||||||
|
|
||||||
|
class AuthenticationError(SessionError):
|
||||||
|
"""The peer or local credentials could not be authenticated."""
|
||||||
|
|
||||||
|
code = 'authentication'
|
||||||
|
message = 'authentication failed'
|
||||||
|
|
||||||
|
|
||||||
|
class AuthorizationError(SmartThingsLocalError, PermissionError):
|
||||||
|
"""The authenticated peer is not authorized for an operation."""
|
||||||
|
|
||||||
|
code = 'authorization'
|
||||||
|
message = 'operation is not authorized'
|
||||||
|
|
||||||
|
|
||||||
|
class SessionTimeoutError(SmartThingsLocalError, TimeoutError):
|
||||||
|
"""A bounded session operation exceeded its deadline."""
|
||||||
|
|
||||||
|
code = 'timeout'
|
||||||
|
message = 'session operation timed out'
|
||||||
|
|
||||||
|
|
||||||
|
class SessionClosedError(SessionError):
|
||||||
|
"""An operation was attempted on a closed session."""
|
||||||
|
|
||||||
|
code = 'session_closed'
|
||||||
|
message = 'session is closed'
|
||||||
|
|
||||||
|
|
||||||
|
class MalformedMessageError(SmartThingsLocalError, ValueError):
|
||||||
|
"""A protocol message could not be decoded safely."""
|
||||||
|
|
||||||
|
code = 'malformed_message'
|
||||||
|
message = 'malformed protocol message'
|
||||||
|
|
||||||
|
|
||||||
|
class BlockwiseError(SessionError):
|
||||||
|
"""A Block1 or Block2 transfer violated its bounded contract."""
|
||||||
|
|
||||||
|
code = 'blockwise'
|
||||||
|
message = 'blockwise transfer failed'
|
||||||
|
|
||||||
|
|
||||||
|
class ObserveError(SessionError):
|
||||||
|
"""A CoAP Observe relation could not be established or maintained."""
|
||||||
|
|
||||||
|
code = 'observe'
|
||||||
|
message = 'Observe relation failed'
|
||||||
@@ -7,6 +7,8 @@ independently.
|
|||||||
"""
|
"""
|
||||||
import struct
|
import struct
|
||||||
|
|
||||||
|
from ..errors import MalformedMessageError
|
||||||
|
|
||||||
# CoAP option numbers (RFC 7252 + 7641 + 7959)
|
# CoAP option numbers (RFC 7252 + 7641 + 7959)
|
||||||
URI_PATH = 11
|
URI_PATH = 11
|
||||||
URI_QUERY = 15
|
URI_QUERY = 15
|
||||||
@@ -81,7 +83,7 @@ def parse_coap(data):
|
|||||||
elif d_nib == 14:
|
elif d_nib == 14:
|
||||||
delta = 269 + int.from_bytes(data[i:i + 2], 'big'); i += 2
|
delta = 269 + int.from_bytes(data[i:i + 2], 'big'); i += 2
|
||||||
elif d_nib == 15:
|
elif d_nib == 15:
|
||||||
raise ValueError("reserved option delta nibble 15")
|
raise MalformedMessageError()
|
||||||
else:
|
else:
|
||||||
delta = d_nib
|
delta = d_nib
|
||||||
if l_nib == 13:
|
if l_nib == 13:
|
||||||
@@ -89,7 +91,7 @@ def parse_coap(data):
|
|||||||
elif l_nib == 14:
|
elif l_nib == 14:
|
||||||
length = 269 + int.from_bytes(data[i:i + 2], 'big'); i += 2
|
length = 269 + int.from_bytes(data[i:i + 2], 'big'); i += 2
|
||||||
elif l_nib == 15:
|
elif l_nib == 15:
|
||||||
raise ValueError("reserved option length nibble 15")
|
raise MalformedMessageError()
|
||||||
else:
|
else:
|
||||||
length = l_nib
|
length = l_nib
|
||||||
num = prev + delta
|
num = prev + delta
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import time
|
|||||||
|
|
||||||
from OpenSSL import SSL
|
from OpenSSL import SSL
|
||||||
|
|
||||||
|
from ..errors import ProbeError
|
||||||
from .coap import split_dtls
|
from .coap import split_dtls
|
||||||
from .dtls_session import _OCF_ROOT_CA, _load_pem_chain
|
from .dtls_session import _OCF_ROOT_CA, _load_pem_chain
|
||||||
|
|
||||||
@@ -214,10 +215,10 @@ def probe(host, port, *, cert_pem=None, key_pem=None,
|
|||||||
break
|
break
|
||||||
except SSL.WantReadError:
|
except SSL.WantReadError:
|
||||||
pass
|
pass
|
||||||
except SSL.Error as e:
|
except SSL.Error:
|
||||||
# A fatal Alert lands here; the alert record was already
|
# A fatal Alert lands here; the alert record was already
|
||||||
# captured below, so classification still works.
|
# captured below, so classification still works.
|
||||||
result.error = str(e)
|
result.error = ProbeError()
|
||||||
break
|
break
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ from pathlib import Path
|
|||||||
|
|
||||||
from OpenSSL import SSL
|
from OpenSSL import SSL
|
||||||
|
|
||||||
|
from ..errors import (
|
||||||
|
BlockwiseError,
|
||||||
|
SessionClosedError,
|
||||||
|
SessionError,
|
||||||
|
SessionTimeoutError,
|
||||||
|
)
|
||||||
from .coap import (
|
from .coap import (
|
||||||
URI_PATH, URI_QUERY, OBSERVE, CONTENT_FORMAT, ACCEPT, BLOCK2, SIZE2,
|
URI_PATH, URI_QUERY, OBSERVE, CONTENT_FORMAT, ACCEPT, BLOCK2, SIZE2,
|
||||||
TYPE_CON, TYPE_NON, TYPE_ACK, TYPE_RST,
|
TYPE_CON, TYPE_NON, TYPE_ACK, TYPE_RST,
|
||||||
@@ -215,15 +221,17 @@ class DtlsCoapSession:
|
|||||||
dest = (self.host, self.port)
|
dest = (self.host, self.port)
|
||||||
|
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
|
backend_failed = False
|
||||||
while time.time() - t0 < self.HANDSHAKE_TIMEOUT_S:
|
while time.time() - t0 < self.HANDSHAKE_TIMEOUT_S:
|
||||||
try:
|
try:
|
||||||
conn.do_handshake()
|
conn.do_handshake()
|
||||||
break
|
break
|
||||||
except SSL.WantReadError:
|
except SSL.WantReadError:
|
||||||
pass
|
pass
|
||||||
except SSL.Error as e:
|
except SSL.Error:
|
||||||
sock.close()
|
sock.close()
|
||||||
raise ConnectionError(f"DTLS handshake error: {e}") from e
|
backend_failed = True
|
||||||
|
break
|
||||||
try:
|
try:
|
||||||
o = conn.bio_read(65535)
|
o = conn.bio_read(65535)
|
||||||
if o:
|
if o:
|
||||||
@@ -240,8 +248,9 @@ class DtlsCoapSession:
|
|||||||
time.sleep(0.05)
|
time.sleep(0.05)
|
||||||
else:
|
else:
|
||||||
sock.close()
|
sock.close()
|
||||||
raise TimeoutError(
|
raise SessionTimeoutError()
|
||||||
f"DTLS handshake timeout to {self.host}:{self.port}")
|
if backend_failed:
|
||||||
|
raise SessionError() from ConnectionError('DTLS backend failed')
|
||||||
|
|
||||||
self.sock = sock
|
self.sock = sock
|
||||||
self.conn = conn
|
self.conn = conn
|
||||||
@@ -303,7 +312,7 @@ class DtlsCoapSession:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
for tok, (ev, container) in list(self._pending.items()):
|
for tok, (ev, container) in list(self._pending.items()):
|
||||||
container.setdefault('err', 'socket closed')
|
container.setdefault('err', SessionClosedError())
|
||||||
ev.set()
|
ev.set()
|
||||||
self._pending.clear()
|
self._pending.clear()
|
||||||
self._observe_tokens.clear()
|
self._observe_tokens.clear()
|
||||||
@@ -340,7 +349,7 @@ class DtlsCoapSession:
|
|||||||
BIO-drain so two writers can't interleave records."""
|
BIO-drain so two writers can't interleave records."""
|
||||||
with self._send_lock:
|
with self._send_lock:
|
||||||
if self.conn is None:
|
if self.conn is None:
|
||||||
raise ConnectionError("DTLS session closed")
|
raise SessionClosedError()
|
||||||
try:
|
try:
|
||||||
self.conn.send(datagram)
|
self.conn.send(datagram)
|
||||||
self._last_send_ts = time.monotonic()
|
self._last_send_ts = time.monotonic()
|
||||||
@@ -412,7 +421,7 @@ class DtlsCoapSession:
|
|||||||
finally:
|
finally:
|
||||||
# Make sure pending waiters don't hang if the reader dies.
|
# Make sure pending waiters don't hang if the reader dies.
|
||||||
for tok, (ev, container) in list(self._pending.items()):
|
for tok, (ev, container) in list(self._pending.items()):
|
||||||
container.setdefault('err', 'reader exited')
|
container.setdefault('err', SessionClosedError())
|
||||||
ev.set()
|
ev.set()
|
||||||
|
|
||||||
def _dispatch_coap(self, datagram):
|
def _dispatch_coap(self, datagram):
|
||||||
@@ -482,7 +491,7 @@ class DtlsCoapSession:
|
|||||||
token, and dropping a fresh token on block 1+ silently drops
|
token, and dropping a fresh token on block 1+ silently drops
|
||||||
the request."""
|
the request."""
|
||||||
if self.conn is None:
|
if self.conn is None:
|
||||||
raise ConnectionError("DTLS session closed")
|
raise SessionClosedError()
|
||||||
tok = self._next_tok()
|
tok = self._next_tok()
|
||||||
blob = b''
|
blob = b''
|
||||||
num = 0
|
num = 0
|
||||||
@@ -518,8 +527,7 @@ class DtlsCoapSession:
|
|||||||
"GET %s /%s block %d: timed out after %d attempt(s)",
|
"GET %s /%s block %d: timed out after %d attempt(s)",
|
||||||
self.host, '/'.join(path_segs), num, attempt + 1,
|
self.host, '/'.join(path_segs), num, attempt + 1,
|
||||||
)
|
)
|
||||||
raise TimeoutError(
|
raise SessionTimeoutError()
|
||||||
f"GET /{'/'.join(path_segs)} block {num} timeout")
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"GET %s /%s block %d: attempt %d/%d timeout, retrying",
|
"GET %s /%s block %d: attempt %d/%d timeout, retrying",
|
||||||
self.host, '/'.join(path_segs), num,
|
self.host, '/'.join(path_segs), num,
|
||||||
@@ -528,7 +536,7 @@ class DtlsCoapSession:
|
|||||||
finally:
|
finally:
|
||||||
self._pending.pop(tok, None)
|
self._pending.pop(tok, None)
|
||||||
if 'err' in container:
|
if 'err' in container:
|
||||||
raise ConnectionError(container['err'])
|
raise container['err']
|
||||||
|
|
||||||
code = container['code']
|
code = container['code']
|
||||||
payload = container['payload']
|
payload = container['payload']
|
||||||
@@ -552,16 +560,14 @@ class DtlsCoapSession:
|
|||||||
break
|
break
|
||||||
num += 1
|
num += 1
|
||||||
if num > self.MAX_BLOCKS:
|
if num > self.MAX_BLOCKS:
|
||||||
raise ConnectionError(
|
raise BlockwiseError()
|
||||||
f"GET /{'/'.join(path_segs)}: >{self.MAX_BLOCKS} "
|
|
||||||
f"blocks, aborting")
|
|
||||||
return last_code, blob
|
return last_code, blob
|
||||||
|
|
||||||
def post(self, path_segs, body_cbor, timeout=8.0):
|
def post(self, path_segs, body_cbor, timeout=8.0):
|
||||||
"""Single-frame POST with a CBOR-encoded body. Returns
|
"""Single-frame POST with a CBOR-encoded body. Returns
|
||||||
(code, payload_bytes). body_cbor must already be encoded."""
|
(code, payload_bytes). body_cbor must already be encoded."""
|
||||||
if self.conn is None:
|
if self.conn is None:
|
||||||
raise ConnectionError("DTLS session closed")
|
raise SessionClosedError()
|
||||||
tok = self._next_tok()
|
tok = self._next_tok()
|
||||||
mid = self._next_mid()
|
mid = self._next_mid()
|
||||||
opts = [(URI_PATH, s.encode()) for s in path_segs]
|
opts = [(URI_PATH, s.encode()) for s in path_segs]
|
||||||
@@ -575,10 +581,9 @@ class DtlsCoapSession:
|
|||||||
try:
|
try:
|
||||||
self._send_dgram(datagram)
|
self._send_dgram(datagram)
|
||||||
if not ev.wait(timeout):
|
if not ev.wait(timeout):
|
||||||
raise TimeoutError(
|
raise SessionTimeoutError()
|
||||||
f"POST /{'/'.join(path_segs)} timeout")
|
|
||||||
if 'err' in container:
|
if 'err' in container:
|
||||||
raise ConnectionError(container['err'])
|
raise container['err']
|
||||||
return container['code'], container['payload']
|
return container['code'], container['payload']
|
||||||
finally:
|
finally:
|
||||||
self._pending.pop(tok, None)
|
self._pending.pop(tok, None)
|
||||||
@@ -596,7 +601,7 @@ class DtlsCoapSession:
|
|||||||
`last_success_ts`, surfaced through KeepaliveTask's
|
`last_success_ts`, surfaced through KeepaliveTask's
|
||||||
`liveness_fn`."""
|
`liveness_fn`."""
|
||||||
if self.conn is None:
|
if self.conn is None:
|
||||||
raise ConnectionError("DTLS session closed")
|
raise SessionClosedError()
|
||||||
mid = self._next_mid()
|
mid = self._next_mid()
|
||||||
self._send_dgram(build_coap(TYPE_CON, 0, mid, b'', []))
|
self._send_dgram(build_coap(TYPE_CON, 0, mid, b'', []))
|
||||||
return mid
|
return mid
|
||||||
@@ -614,7 +619,7 @@ class DtlsCoapSession:
|
|||||||
old token gets dropped as 'stale' — acceptable for a 6h-scale
|
old token gets dropped as 'stale' — acceptable for a 6h-scale
|
||||||
safety net."""
|
safety net."""
|
||||||
if self.conn is None:
|
if self.conn is None:
|
||||||
raise ConnectionError("DTLS session closed")
|
raise SessionClosedError()
|
||||||
for tok, href in list(self._observe_tokens.items()):
|
for tok, href in list(self._observe_tokens.items()):
|
||||||
segs = [s for s in href.split('/') if s]
|
segs = [s for s in href.split('/') if s]
|
||||||
try:
|
try:
|
||||||
@@ -638,7 +643,7 @@ class DtlsCoapSession:
|
|||||||
Returns the token used (in case the caller wants to deregister
|
Returns the token used (in case the caller wants to deregister
|
||||||
later)."""
|
later)."""
|
||||||
if self.conn is None:
|
if self.conn is None:
|
||||||
raise ConnectionError("DTLS session closed")
|
raise SessionClosedError()
|
||||||
tok = self._next_observe_tok()
|
tok = self._next_observe_tok()
|
||||||
href = '/' + '/'.join(path_segs)
|
href = '/' + '/'.join(path_segs)
|
||||||
# Register the token BEFORE sending — otherwise the device
|
# Register the token BEFORE sending — otherwise the device
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from smartthings_local.errors import MalformedMessageError
|
||||||
from smartthings_local.protocol.coap import (
|
from smartthings_local.protocol.coap import (
|
||||||
build_coap, parse_coap, encode_options, block_value, fmt_code,
|
build_coap, parse_coap, encode_options, block_value, fmt_code,
|
||||||
TYPE_CON, METHOD_GET, URI_PATH, ACCEPT, CF_CBOR, BLOCK2,
|
TYPE_CON, METHOD_GET, URI_PATH, ACCEPT, CF_CBOR, BLOCK2,
|
||||||
@@ -43,3 +46,13 @@ def test_block_value_promotes_to_two_bytes_when_num_is_large():
|
|||||||
def test_fmt_code_formats_class_dot_detail():
|
def test_fmt_code_formats_class_dot_detail():
|
||||||
assert fmt_code(0x45) == '2.05'
|
assert fmt_code(0x45) == '2.05'
|
||||||
assert fmt_code(0x84) == '4.04'
|
assert fmt_code(0x84) == '4.04'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('option_header', (b'\xf0', b'\x0f'))
|
||||||
|
def test_reserved_option_nibbles_raise_classified_value_error(option_header):
|
||||||
|
datagram = b'\x40\x01\x00\x01' + option_header
|
||||||
|
|
||||||
|
with pytest.raises(MalformedMessageError) as exc:
|
||||||
|
parse_coap(datagram)
|
||||||
|
|
||||||
|
assert isinstance(exc.value, ValueError)
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import socket
|
import socket
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
from smartthings_local.errors import ProbeError
|
||||||
from smartthings_local.protocol import dtls_probe as p
|
from smartthings_local.protocol import dtls_probe as p
|
||||||
|
|
||||||
|
|
||||||
@@ -156,4 +157,4 @@ def test_diagnostic_mode_feeds_server_flight_back(monkeypatch):
|
|||||||
_patch_sock(monkeypatch, fake)
|
_patch_sock(monkeypatch, fake)
|
||||||
r = p.probe('127.0.0.1', 5684, stateless=False, timeout=3.0)
|
r = p.probe('127.0.0.1', 5684, stateless=False, timeout=3.0)
|
||||||
assert r.outcome == p.LIVE # HVR still proved liveness
|
assert r.outcome == p.LIVE # HVR still proved liveness
|
||||||
assert r.error is not None # OpenSSL processed the fed-back flight
|
assert isinstance(r.error, ProbeError) # OpenSSL processed the flight
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import traceback
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from OpenSSL import SSL
|
||||||
|
|
||||||
|
from smartthings_local.errors import (
|
||||||
|
AuthenticationError,
|
||||||
|
AuthorizationError,
|
||||||
|
BlockwiseError,
|
||||||
|
EndpointError,
|
||||||
|
MalformedMessageError,
|
||||||
|
ObserveError,
|
||||||
|
ProbeError,
|
||||||
|
SessionClosedError,
|
||||||
|
SessionError,
|
||||||
|
SessionTimeoutError,
|
||||||
|
SmartThingsLocalError,
|
||||||
|
)
|
||||||
|
from smartthings_local.protocol import dtls_session
|
||||||
|
from smartthings_local.protocol.dtls_session import DtlsCoapSession
|
||||||
|
|
||||||
|
ERROR_TYPES = (
|
||||||
|
EndpointError,
|
||||||
|
ProbeError,
|
||||||
|
SessionError,
|
||||||
|
AuthenticationError,
|
||||||
|
AuthorizationError,
|
||||||
|
SessionTimeoutError,
|
||||||
|
SessionClosedError,
|
||||||
|
MalformedMessageError,
|
||||||
|
BlockwiseError,
|
||||||
|
ObserveError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
('error_type', 'legacy_type'),
|
||||||
|
(
|
||||||
|
(EndpointError, OSError),
|
||||||
|
(ProbeError, ConnectionError),
|
||||||
|
(SessionError, ConnectionError),
|
||||||
|
(AuthenticationError, ConnectionError),
|
||||||
|
(AuthorizationError, PermissionError),
|
||||||
|
(SessionTimeoutError, TimeoutError),
|
||||||
|
(SessionClosedError, ConnectionError),
|
||||||
|
(MalformedMessageError, ValueError),
|
||||||
|
(BlockwiseError, ConnectionError),
|
||||||
|
(ObserveError, ConnectionError),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def test_errors_preserve_legacy_builtin_catches(error_type, legacy_type):
|
||||||
|
error = error_type()
|
||||||
|
assert isinstance(error, SmartThingsLocalError)
|
||||||
|
assert isinstance(error, legacy_type)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('error_type', ERROR_TYPES)
|
||||||
|
def test_error_text_is_fixed_and_redacted(error_type):
|
||||||
|
error = error_type()
|
||||||
|
text = f'{error!s} {error!r}'
|
||||||
|
assert error.code
|
||||||
|
assert str(error) == error.message
|
||||||
|
assert repr(error) == f'{error_type.__name__}(code={error.code!r})'
|
||||||
|
assert 'device.example' not in text
|
||||||
|
assert '/synthetic/client.key' not in text
|
||||||
|
assert 'credential-value' not in text
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
error_type('credential-value')
|
||||||
|
|
||||||
|
|
||||||
|
def test_chained_backend_error_is_not_copied_into_public_text():
|
||||||
|
backend = ConnectionError('DTLS backend failed')
|
||||||
|
|
||||||
|
try:
|
||||||
|
raise SessionError() from backend
|
||||||
|
except SessionError as error:
|
||||||
|
assert error.__cause__ is backend
|
||||||
|
formatted = ''.join(traceback.format_exception(error))
|
||||||
|
assert 'DTLS backend failed' in formatted
|
||||||
|
assert 'device.example' not in formatted
|
||||||
|
assert 'credential-value' not in formatted
|
||||||
|
|
||||||
|
|
||||||
|
def test_handshake_error_is_classified_without_backend_text(monkeypatch):
|
||||||
|
class FakeContext:
|
||||||
|
def load_verify_locations(self, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def set_verify(self, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def set_cipher_list(self, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def use_certificate_chain_file(self, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def use_privatekey_file(self, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def check_privatekey(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class FakeConnection:
|
||||||
|
def set_connect_state(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def set_ciphertext_mtu(self, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def do_handshake(self):
|
||||||
|
raise SSL.Error('credential-value at device.example')
|
||||||
|
|
||||||
|
class FakeSocket:
|
||||||
|
closed = False
|
||||||
|
|
||||||
|
def settimeout(self, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
fake_socket = FakeSocket()
|
||||||
|
monkeypatch.setattr(dtls_session.SSL, 'Context', lambda *args: FakeContext())
|
||||||
|
monkeypatch.setattr(
|
||||||
|
dtls_session.SSL, 'Connection', lambda *args: FakeConnection())
|
||||||
|
monkeypatch.setattr(
|
||||||
|
dtls_session.socket, 'socket', lambda *args: fake_socket)
|
||||||
|
|
||||||
|
session = DtlsCoapSession(
|
||||||
|
'device.example', 5684,
|
||||||
|
cert_path='/synthetic/client.pem',
|
||||||
|
key_path='/synthetic/client.key',
|
||||||
|
)
|
||||||
|
with pytest.raises(SessionError) as exc:
|
||||||
|
session.connect()
|
||||||
|
|
||||||
|
assert fake_socket.closed
|
||||||
|
assert isinstance(exc.value, ConnectionError)
|
||||||
|
assert isinstance(exc.value.__cause__, ConnectionError)
|
||||||
|
assert exc.value.__context__ is None
|
||||||
|
formatted = ''.join(traceback.format_exception(exc.value))
|
||||||
|
assert 'DTLS backend failed' in formatted
|
||||||
|
assert 'device.example' not in formatted
|
||||||
|
assert 'credential-value' not in formatted
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'operation',
|
||||||
|
(
|
||||||
|
lambda session: session.get(['resource']),
|
||||||
|
lambda session: session.post(['resource'], b'payload'),
|
||||||
|
lambda session: session.ping(),
|
||||||
|
lambda session: session.refresh_observes([]),
|
||||||
|
lambda session: session.subscribe(['resource']),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def test_closed_session_operations_raise_classified_error(operation):
|
||||||
|
session = DtlsCoapSession(
|
||||||
|
'device.example', 5684,
|
||||||
|
cert_path='/synthetic/client.pem',
|
||||||
|
key_path='/synthetic/client.key',
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(SessionClosedError):
|
||||||
|
operation(session)
|
||||||
Reference in new issue
Block a user