diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6ffeeec --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,64 @@ +# Changelog + +Notable changes to `dodomain-sdk`. The import package is `dodomain`. + +## 0.2.0 + +Full parity with the API's current `/v1` surface: every callable REST operation +now has a method, sync and async. Nothing was removed and no existing return value +changed shape, so upgrading from 0.1.0 is a drop-in. + +### Added + +* **`connections.get(connection_id)`** — read one connection by the id every + `connection.*` webhook carries, instead of listing and filtering (which is + impossible past the paging ceiling without walking every cursor). Returns a + connection even after it was disconnected, unlike the list default. +* **`sessions.get(session_id)`** — the integrator-authed session read, and the + only one that answers after a session expired (`sessions.retrieve` raises + `ExpiredError` forever once the TTL passes). Addressable by the `sessionId` + webhooks carry. Returns the new `IntegratorSession`, which is a genuinely + different shape from `PublicSession`: composed records with **no `value`**, plus + `app_id`, `connection_id` and a server-derived `expired`. +* **`client.webhook_endpoints`** — `list`, `create`, `get`, `update`, `delete`, + `rotate_secret`. Secret-key only; an OAuth token is refused with a 403 you can + now recognise via `PermissionError_.secret_key_required`. The signing secret is + show-once, so it appears only on `create` and `rotate_secret` results and is + kept out of their `repr`. `get` is a client-side lookup over `list` — the API + has no read-one route — and says so in its docstring and its `status_code == 0` + `NotFoundError`. +* **`client.keys.rotate()`** — self-rotate the calling app's secret key, the + automatable half of credential lifecycle. No grace window: the old key stops + working the instant the response is produced, and the response is the only copy + of the new one. There is deliberately no create/list/revoke. +* **`Connection.record_fqdns`** — the names doDomain actually monitors. + `Connection.fqdn` has always held the session *domain* rather than a record + name; it keeps its value for compatibility, and this is the honest answer. +* **`Session.records` and `Session.warnings`** — the composed names a create will + be verified at (where a doubled label like `links.links.acme.com` becomes + visible immediately) and any advisory attached to an accepted request. +* **`PermissionError_.secret_key_required`** — distinguishes "this endpoint needs + a `dd_sk_` key" from a missing OAuth scope. No scope fixes the former. +* New models: `ComposedRecord`, `SessionWarning`, `IntegratorSession`, + `WebhookEndpoint`, `WebhookEndpointWithSecret`, `DeletedWebhookEndpoint`, + `RotatedSecretKey`. + +### Changed + +* **Webhook documentation now describes the body that is actually delivered.** + 0.1.0 documented the pre-cutover `{event, data}` shape; the wire envelope has + been `{id, type, occurredAt, data}` plus a deprecated `event` alias since + 2026-08-06. Read `type`, dedupe on `id`, treat `event` as legacy. + `verify_webhook` itself is unchanged — it was wire-format agnostic by design and + needed no cutover. +* Additive response fields parse tolerantly: a payload recorded before `records` + or `recordFqdns` existed still parses, while a field that is *present* with the + wrong type still fails loudly. +* The README's claim that doDomain publishes no OpenAPI document was false; it is + served at . + +## 0.1.0 + +First release. Sync and async clients, connect sessions, connections, domain +pre-flight, apps, webhook signature verification, the full error hierarchy, and a +retry policy that never replays a `POST`. diff --git a/README.md b/README.md index c6f7fdc..ee0b7ae 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,10 @@ You mint a **connect session**, send the customer to a hosted flow that detects their DNS provider and walks them through (or one-clicks) the records, and you get a signed webhook when the domain goes live. -This SDK covers **all 9 REST operations**, sync and async, plus webhook signature -verification. +This SDK covers **all 16 callable REST operations**, sync and async, plus webhook +signature verification. (The API's other four routes are browser navigations that +answer `302` on every path; the SDK exposes them as URL builders and never fetches +them.) ## Install @@ -59,14 +61,39 @@ session = client.sessions.create( print(session.connect_url) # send your customer here print(session.expires_at) # 24 hours from now, timezone-aware + +for record in session.records: + print(record.fqdn) # the name we will actually verify — check this ``` Render `session.connect_url` as a link or redirect. When the customer finishes, doDomain fires a `connection.verified` webhook carrying `session.id` as `sessionId`, so you can correlate it back to the row you just wrote. -You can also drive the flow yourself with the **token-public** routes — they take -the session token in the path as the capability and send no credential at all: +**Read `session.records` before you show the customer anything.** The API composes +each `host` under your `domain`, so `domain="links.acme.com"` with `host="links"` +is verified at `links.links.acme.com` — a doubled label that used to surface only +as a mysteriously failing verify. `session.warnings` carries advisories about a +request that was accepted anyway (`duplicate_host_label` is the one that exists +today); a warning never changes the status code. + +### Reading a session back + +Two different reads, and the difference matters. **From your server, use +`sessions.get(session_id)`** — it takes your credential, it is addressable by the +`sessionId` every webhook carries, and it still answers after the session expired: + +```python +state = client.sessions.get("cs_01HZX") +state.status # "verified" +state.expired # True once the 24h TTL passed — even before the reaper catches up +state.connection_id # the DomainConnection id, or None if it never finalized +state.records # composed names (type/host/fqdn) — no `value` on this arm +``` + +The **token-public** routes are the other read: they take the session token in the +path as the capability, send no credential at all, and are what a browser or a +customer-side process can call: ```python public = client.sessions.retrieve(session.token) # status, records, detected tier @@ -77,6 +104,9 @@ for record in result.records: print(record.fqdn, record.type, record.outcome) ``` +`retrieve` raises `ExpiredError` forever once the TTL passes — correct for a +capability URL, useless for support, which is exactly why `get` exists. + Two endpoints are **browser navigations**, not API calls — they answer `302` on every path. The SDK exposes them as URL builders so you can render your own CTA, and never fetches them: @@ -118,6 +148,12 @@ A connection is a customer domain that went live. doDomain keeps checking its DN and tells you when it drifts. ```python +# Hold an id from a webhook? Read that one connection directly. +conn = client.connections.get("conn_123") +conn.status # "active" | "broken" +conn.record_fqdns # ("status.customer.com",) — the names we actually monitor +conn.disconnected_at # not None => monitoring stopped + page = client.connections.list(limit=100) page.connections # tuple[Connection, ...] page.next_cursor # str | None — opaque, pass it straight back @@ -151,7 +187,67 @@ result.already_disconnected # False on the call that did it, True on a repeat A connection owned by another app or team answers **404, never 403** — the API refuses to confirm that someone else's id exists, and this SDK does not -reinterpret that as a permission problem. +reinterpret that as a permission problem. `get` is the exception to the list's +default in one way: it *does* return a disconnected connection, because a caller +naming an id already knows the row exists. + +> **Read `record_fqdns`, not `fqdn`.** `Connection.fqdn` has always been written +> as the session's *domain*, so a connection verified for `status.acme.com` +> reports `fqdn="acme.com"`. It cannot be fixed in place — a session may carry +> several records, so there is no single honest "the" fqdn — and it keeps its +> value for the integrators already reading it. `record_fqdns` carries the real +> answer: every name doDomain monitors for that connection. + +## Webhook endpoints + +Manage delivery targets from CI or IaC instead of the dashboard. **Secret-key +only** — an OAuth token is refused with a 403 whose +`exc.secret_key_required` is `True`. + +```python +endpoint = client.webhook_endpoints.create(url="https://acme.example/webhooks/dodomain") +endpoint.secret # "whsec_…" — SHOWN ONCE. Store it now. + +for e in client.webhook_endpoints.list(): + print(e.id, e.url) # never carries a secret + +client.webhook_endpoints.update("whe_123", url="https://acme.example/v2") # secret unchanged +rotated = client.webhook_endpoints.rotate_secret("whe_123") +rotated.secret # the new one, also shown once + +client.webhook_endpoints.delete("whe_123") +``` + +Three things that will bite if assumed away: + +* **The signing secret is show-once.** `create` and `rotate_secret` return it and + nothing else ever does. It is kept out of the object's `repr` so a traceback + cannot spill it into your logs; read it off `.secret`. +* **Rotation is an immediate cutover.** There is no dual-secret window: signatures + switch the moment the call returns, including retries of deliveries created + before it. Deploy the new secret to your receiver first. +* **`get(endpoint_id)` is a client-side lookup over `list()`** — the API has no + read-one route — so it costs one list request, and the `NotFoundError` it raises + carries `status_code == 0` because no 404 came back from the server. + +## Rotating your secret key + +```python +rotated = client.keys.rotate() +rotated.secret_key # the NEW dd_sk_… — the only copy that will ever exist +rotated.public_key # unchanged, so CI can assert it rewrote the right app +``` + +**There is no grace window.** The key you authenticated the call with stops +working the instant the response is produced. Write `rotated.secret_key` to your +secret store before doing anything else — drop it and you are locked out until you +rotate again from the dashboard. The client you called it on still holds the old +key; build a new one from the result. + +There is deliberately no `keys.create`, `keys.list` or `keys.revoke`: key +inventory stays behind a human dashboard session, so a stolen key can never mint a +second hidden credential that survives you rotating the one you know about. +Rotating *is* the revoke. ## Domain pre-flight check @@ -186,6 +282,7 @@ doDomain signs every delivery Stripe-style: ``` x-dodomain-signature: t=,v1= x-dodomain-event: connection.verified +x-dodomain-delivery-id: whd_… ``` Verify against the **raw** request body, before any JSON parsing — re-serializing @@ -218,15 +315,37 @@ async def handle(request): attacker cannot turn a crafted header into a 500 inside your handler. * Comparison is constant-time. +### The delivered body + +```json +{ + "id": "whd_…", + "type": "connection.verified", + "occurredAt": "2026-08-17T10:00:00.000Z", + "data": { "sessionId": "cs_…", "connectionId": "conn_…" }, + "event": "connection.verified" +} +``` + +Read `type`. **Dedupe on `id`** — it is stable across retries and is the same +value as the `x-dodomain-delivery-id` header, so you can dedupe before parsing the +body at all. `event` is a **deprecated** alias of `type`, byte-identical to it, +kept only so receivers written before the 2026-08-06 envelope cutover keep +parsing; do not write new code against it. + +`data` always carries `sessionId` as your correlation handle, and every payload +that announces a connection also carries `connectionId` — the id +`connections.get` / `reverify` / `disconnect` are keyed by. + Event types: `connection.verified`, `connection.failed`, `connection.disconnected`, `session.completed`, `session.abandoned`. A receiver that does not recognise a type must ignore it — the vocabulary grows additively. -> **No typed event parser ships in 0.1.0, on purpose.** The delivered body is -> still the legacy `{event, data}` shape while the versioned `{id, type, -> occurredAt, data}` envelope waits on a deliberate cutover. `verify_webhook` -> only checks the HMAC and is wire-format agnostic, so it is safe across that -> change; a typed parser would not be. +> **No typed event parser ships, on purpose.** Both the event vocabulary and the +> payload fields grow additively, so a strict parser would reject a delivery the +> day the API adds a type — exactly the failure a webhook receiver must not have. +> `verify_webhook` checks the HMAC and nothing else, which is what makes it safe +> across every additive change. ## Error handling @@ -353,9 +472,13 @@ Python and TypeScript verifiers drifting apart. DODOMAIN_SECRET_KEY="dd_sk_…" pytest tests/e2e -v ``` -This SDK is hand-written against the API's zod contract -(`packages/core/src/schemas.ts`) and its route handlers — doDomain publishes no -OpenAPI document, so there is nothing to generate from. +This SDK is hand-written, not generated. doDomain does publish an OpenAPI 3.1 +document — , generated from the same zod +schemas the route handlers validate with — and it is the right thing to check this +SDK's shapes against, but the hand-written surface is deliberate: the naming, the +sync/async twins, the local validation and the docstrings that explain *why* an +endpoint behaves the way it does are the product here, and none of them survive a +generator. ## License diff --git a/src/dodomain/__init__.py b/src/dodomain/__init__.py index 60720f7..39d226d 100644 --- a/src/dodomain/__init__.py +++ b/src/dodomain/__init__.py @@ -20,7 +20,7 @@ from __future__ import annotations -__version__ = "0.1.0" +__version__ = "0.2.0" from ._client import AsyncDoDomain, DoDomain from ._transport import DEFAULT_BASE_URL, RateLimitSnapshot @@ -44,25 +44,32 @@ from .models import ( App, CheckDomainResult, + ComposedRecord, Confidence, Connection, ConnectionPage, ConnectionStatus, + DeletedWebhookEndpoint, DetectResult, DisconnectResult, DnsRecord, DnsRecordType, DomainConnectDiscovery, DomainConnectRef, + IntegratorSession, Method, ProviderGuide, PublicSession, ReverifyResult, + RotatedSecretKey, Session, + SessionWarning, Tier, VerifyOutcome, VerifyRecord, VerifyResult, + WebhookEndpoint, + WebhookEndpointWithSecret, ) from .webhooks import DEFAULT_TOLERANCE_MS, SIGNATURE_HEADER, verify_webhook @@ -74,11 +81,13 @@ "AsyncDoDomain", "AuthenticationError", "CheckDomainResult", + "ComposedRecord", "Confidence", "ConflictError", "Connection", "ConnectionPage", "ConnectionStatus", + "DeletedWebhookEndpoint", "DetectResult", "DisconnectResult", "DnsRecord", @@ -91,6 +100,7 @@ "DomainConnectDiscovery", "DomainConnectRef", "ExpiredError", + "IntegratorSession", "InternalServerError", "InvalidRequestError", "InvalidResponseError", @@ -104,11 +114,15 @@ "RateLimitError", "RateLimitSnapshot", "ReverifyResult", + "RotatedSecretKey", "Session", + "SessionWarning", "Tier", "VerifyOutcome", "VerifyRecord", "VerifyResult", + "WebhookEndpoint", + "WebhookEndpointWithSecret", "__version__", "verify_webhook", ] diff --git a/src/dodomain/_client.py b/src/dodomain/_client.py index 6d3c37c..f5b578c 100644 --- a/src/dodomain/_client.py +++ b/src/dodomain/_client.py @@ -32,7 +32,9 @@ from .resources.apps import Apps, AsyncApps from .resources.connections import AsyncConnections, Connections from .resources.domains import AsyncDomains, Domains +from .resources.keys import AsyncKeys, Keys from .resources.sessions import AsyncSessions, Sessions +from .resources.webhook_endpoints import AsyncWebhookEndpoints, WebhookEndpoints __all__ = ["AsyncDoDomain", "DoDomain"] @@ -168,6 +170,8 @@ def __init__( self.connections = Connections(self) self.domains = Domains(self) self.apps = Apps(self) + self.webhook_endpoints = WebhookEndpoints(self) + self.keys = Keys(self) def request(self, spec: RequestSpec) -> Any: """Send one request, applying the retry policy, and return parsed JSON.""" @@ -248,6 +252,8 @@ def __init__( self.connections = AsyncConnections(self) self.domains = AsyncDomains(self) self.apps = AsyncApps(self) + self.webhook_endpoints = AsyncWebhookEndpoints(self) + self.keys = AsyncKeys(self) async def request(self, spec: RequestSpec) -> Any: """Send one request, applying the retry policy, and return parsed JSON.""" diff --git a/src/dodomain/errors.py b/src/dodomain/errors.py index 09a168e..3134746 100644 --- a/src/dodomain/errors.py +++ b/src/dodomain/errors.py @@ -153,6 +153,18 @@ def required_scope(self) -> str | None: return scope if isinstance(scope, str) else None return None + @property + def secret_key_required(self) -> bool: + """True when the endpoint refuses OAuth tokens and wants a ``dd_sk_`` key. + + The webhook-endpoint and key-rotation routes are secret-key-only, and the + API says so with 403 + ``details.code == "SECRET_KEY_REQUIRED"`` rather than + 401 — the token is perfectly valid, it simply has no authority here, and a + 401 would send you off re-minting a token that was never the problem. There + is no scope that fixes this: build a client from the app's secret key. + """ + return self._detail_field("code") == "SECRET_KEY_REQUIRED" + class NotFoundError(DoDomainAPIError): """404 ``not_found``. diff --git a/src/dodomain/models.py b/src/dodomain/models.py index 2c91628..79f435f 100644 --- a/src/dodomain/models.py +++ b/src/dodomain/models.py @@ -27,25 +27,32 @@ __all__ = [ "App", "CheckDomainResult", + "ComposedRecord", "Confidence", "Connection", "ConnectionPage", "ConnectionStatus", + "DeletedWebhookEndpoint", "DetectResult", "DisconnectResult", "DnsRecord", "DnsRecordType", "DomainConnectDiscovery", "DomainConnectRef", + "IntegratorSession", "Method", "ProviderGuide", "PublicSession", "ReverifyResult", + "RotatedSecretKey", "Session", + "SessionWarning", "Tier", "VerifyOutcome", "VerifyRecord", "VerifyResult", + "WebhookEndpoint", + "WebhookEndpointWithSecret", ] DnsRecordType = Literal["A", "AAAA", "CNAME", "TXT", "MX"] @@ -55,6 +62,7 @@ ConnectionStatus = Literal["active", "broken"] VerifyOutcome = Literal["verified", "propagating", "absent", "indeterminate", "domain_not_found"] ApexToken = Literal["@", "(blank)", "%domain%"] +WarningCode = Literal["duplicate_host_label"] #: Every DNS record type a connect session may request, from the app repo's one #: record-type home (``packages/core/src/record-capabilities.ts``). @@ -120,6 +128,20 @@ def _req_list(payload: dict[str, Any], key: str) -> list[Any]: return value +def _opt_list(payload: dict[str, Any], key: str) -> list[Any]: + """Read an *additive* array field, treating absence as empty. + + Every array the API has grown since this SDK's first release — ``records`` and + ``warnings`` on a create response, ``recordFqdns`` on a connection — arrived + additively, so a body written before the field existed (a cached response, an + archived payload, an older deployment) must still parse. A field that is + *present* but not an array is still fatal: that is contract drift, not history. + """ + if payload.get(key) is None: + return [] + return _req_list(payload, key) + + def _str_list(payload: dict[str, Any], key: str) -> tuple[str, ...]: return tuple(str(item) for item in _req_list(payload, key)) @@ -212,6 +234,64 @@ def _from_api(cls, payload: Any) -> DnsRecord: ) +@dataclass(frozen=True, slots=True) +class ComposedRecord: + """A requested record paired with the name doDomain will actually look up. + + The API composes ``host`` under the session's ``domain`` — a session for + ``links.acme.com`` with ``host="links"`` is verified at + ``links.links.acme.com``, which is the mistake this shape exists to make + visible at create time rather than at the first failing verify. + + It deliberately carries **no** ``value``: the server omits it (``zComposedRecord`` + picks only ``type`` and ``host`` off the record schema), because these are the + *names* being monitored, not the record contents. Read the value back off the + :class:`DnsRecord` you sent, or off :attr:`PublicSession.records`. + """ + + type: DnsRecordType + host: str + fqdn: str + raw: dict[str, Any] | None = field(default=None, compare=False, repr=False) + + @classmethod + def _from_api(cls, payload: Any) -> ComposedRecord: + data = _obj(payload, "composed record") + return cls( + type=_literal(data, "type", RECORD_TYPES), + host=_req_str(data, "host"), + fqdn=_req_str(data, "fqdn"), + raw=data, + ) + + +@dataclass(frozen=True, slots=True) +class SessionWarning: + """A non-fatal advisory on an *accepted* ``sessions.create``. + + A warning never changes the status code — the session was created either way. + Branch on :attr:`code`; the vocabulary is closed and grows additively, so an + unrecognised code must be treated as advisory rather than as an error. + """ + + code: str + message: str + host: str + fqdn: str + raw: dict[str, Any] | None = field(default=None, compare=False, repr=False) + + @classmethod + def _from_api(cls, payload: Any) -> SessionWarning: + data = _obj(payload, "session warning") + return cls( + code=_req_str(data, "code"), + message=_req_str(data, "message"), + host=_req_str(data, "host"), + fqdn=_req_str(data, "fqdn"), + raw=data, + ) + + @dataclass(frozen=True, slots=True) class Session: """A freshly minted connect session — the response of ``sessions.create``. @@ -221,12 +301,24 @@ class Session: token: The capability for the token-public routes and the hosted flow. expires_at: 24 hours after creation. connect_url: Send the customer here. + records: The fully-qualified names this session will be verified at — one + per record you sent. Check these before showing the customer anything: + they are where a doubled label (``links.links.acme.com``) becomes + obvious. + warnings: Advisories about the request that was nonetheless accepted. + Empty for a clean create. """ id: str token: str expires_at: datetime connect_url: str + #: Both additive fields carry defaults and therefore sit after the four + #: original ones — a Python dataclass cannot put a defaulted field before an + #: undefaulted one, and reordering the originals would break positional + #: construction for anyone who already writes ``Session(...)`` in a test. + records: tuple[ComposedRecord, ...] = () + warnings: tuple[SessionWarning, ...] = () base_url: str = "https://app.dodomain.io" raw: dict[str, Any] | None = field(default=None, compare=False, repr=False) @@ -256,6 +348,8 @@ def _from_api(cls, payload: Any, *, base_url: str) -> Session: token=_req_str(data, "token"), expires_at=_req_datetime(data, "expiresAt"), connect_url=_req_str(data, "connectUrl"), + records=tuple(ComposedRecord._from_api(item) for item in _opt_list(data, "records")), + warnings=tuple(SessionWarning._from_api(item) for item in _opt_list(data, "warnings")), base_url=base_url, raw=data, ) @@ -298,6 +392,72 @@ def _from_api(cls, payload: Any) -> PublicSession: ) +@dataclass(frozen=True, slots=True) +class IntegratorSession: + """A session read back **by its id, with your credential** — ``sessions.get``. + + The same path as :meth:`~dodomain.resources.sessions.Sessions.retrieve` serves + both arms, discriminated by the shape of the segment, and the two answer + genuinely different shapes. Two things only this one can do: + + * **It is addressable by the id webhooks carry.** Every payload names + ``sessionId``, never the token, so a ``session.abandoned`` receiver can ask + what actually happened without having stored the token at creation. + * **It reads an expired session.** The token arm answers 410 forever once the + TTL passes — correct for a capability URL, useless for support, because the + moment you most want the final state is after the session died. + + Two shape differences from :class:`PublicSession` that will bite if assumed away: + + * :attr:`records` are :class:`ComposedRecord`s — ``type``/``host``/``fqdn``, and + **no ``value``**. The server omits it here. + * There is no ``return_url``; there is an ``app_id``, a ``connection_id`` and a + derived :attr:`expired`. + + ``status`` and ``tier`` stay loose (``str`` / ``int | None``) for the same reason + they do on :class:`PublicSession`: the server types them as a bare string and a + nullable int, and a ``Literal`` here would turn an additive server-side status + into a client crash. + """ + + id: str + app_id: str + domain: str + records: tuple[ComposedRecord, ...] + recipe: str | None + status: str + tier: int | None + detected_provider: str | None + #: The ``DomainConnection.id`` once the session finalized; ``None`` until then. + connection_id: str | None + created_at: datetime + expires_at: datetime + #: Derived server-side at read (``expires_at <= now``), so it is already ``True`` + #: in the window before the reaper persists ``status == "expired"``. Trust this + #: over ``status`` when you need to know whether the session is over. + expired: bool + raw: dict[str, Any] | None = field(default=None, compare=False, repr=False) + + @classmethod + def _from_api(cls, payload: Any) -> IntegratorSession: + data = _obj(payload, "session") + return cls( + id=_req_str(data, "id"), + app_id=_req_str(data, "appId"), + domain=_req_str(data, "domain"), + records=tuple(ComposedRecord._from_api(item) for item in _req_list(data, "records")), + recipe=_opt_str(data, "recipe"), + status=_req_str(data, "status"), + tier=_opt_int(data, "tier"), + detected_provider=_opt_str(data, "detectedProvider"), + connection_id=_opt_str(data, "connectionId"), + created_at=_req_datetime(data, "createdAt"), + expires_at=_req_datetime(data, "expiresAt"), + expired=_req_bool(data, "expired"), + raw=data, + ) + + @dataclass(frozen=True, slots=True) class ProviderGuide: """Copy-ready manual instructions for the detected DNS provider.""" @@ -504,6 +664,13 @@ class Connection: ``status`` keeps the last observed DNS health even after a disconnect — read ``disconnected_at is not None`` as "monitoring stopped", not ``status``. + + **Read :attr:`record_fqdns`, not :attr:`fqdn`.** ``fqdn`` has always been + written as the session's *domain*, so a connection verified for the record + ``status.acme.com`` reports ``fqdn="acme.com"``. It is not fixable in place (a + session may carry several records, so there is no single honest "the" fqdn) and + it keeps its value for the integrators already reading it. Treat it as an + alias of :attr:`domain`. """ id: str @@ -517,6 +684,11 @@ class Connection: broken_at: datetime | None disconnected_at: datetime | None created_at: datetime + #: Every fully-qualified name doDomain actually monitors for this connection. + #: Additive on the wire, so it carries a default and sits after the original + #: fields; empty only for a session whose records are missing or malformed, or + #: for a payload recorded before the API grew the field. + record_fqdns: tuple[str, ...] = () raw: dict[str, Any] | None = field(default=None, compare=False, repr=False) @classmethod @@ -534,6 +706,7 @@ def _from_api(cls, payload: Any) -> Connection: broken_at=_opt_datetime(data, "brokenAt"), disconnected_at=_opt_datetime(data, "disconnectedAt"), created_at=_req_datetime(data, "createdAt"), + record_fqdns=tuple(str(item) for item in _opt_list(data, "recordFqdns")), raw=data, ) @@ -636,3 +809,138 @@ class ReverifyResult: def _from_api(cls, payload: Any) -> ReverifyResult: data = _obj(payload, "reverify result") return cls(accepted=_req_bool(data, "accepted"), raw=data) + + +@dataclass(frozen=True, slots=True) +class WebhookEndpoint: + """One delivery target for your app's webhooks. + + **No ``secret`` field, ever.** The signing secret is returned once, by + ``create`` and by ``rotate_secret``, on :class:`WebhookEndpointWithSecret`. A + secret on this shape would turn "list your endpoints" into a + secret-disclosure endpoint, so no read surface carries one. + """ + + id: str + app_id: str + #: The *normalized* URL the server stored, not the string you sent — a + #: trailing-slash variant comes back canonical. + url: str + created_at: datetime + raw: dict[str, Any] | None = field(default=None, compare=False, repr=False) + + @classmethod + def _from_api(cls, payload: Any) -> WebhookEndpoint: + data = _obj(payload, "webhook endpoint") + return cls( + id=_req_str(data, "id"), + app_id=_req_str(data, "appId"), + url=_req_str(data, "url"), + created_at=_req_datetime(data, "createdAt"), + raw=data, + ) + + +@dataclass(frozen=True, slots=True) +class WebhookEndpointWithSecret: + """An endpoint plus its plaintext signing secret — **shown once**. + + Returned only by ``webhook_endpoints.create`` and + ``webhook_endpoints.rotate_secret``. Persist :attr:`secret` from this object + now: no read surface returns it again, and rotation is the only way back. + + A sibling of :class:`WebhookEndpoint` rather than a subclass of it, so that a + value carrying a secret can never be passed where a secret-free summary is + expected — and so ``isinstance(x, WebhookEndpoint)`` stays a reliable "this one + is safe to log". + + Rotation is an **immediate cutover** — the worker reads the secret live at + delivery time, so signatures switch at once, including retries of deliveries + created before the rotation. There is no dual-secret window, so deploy the new + secret to your receiver promptly. + """ + + id: str + app_id: str + url: str + created_at: datetime + #: ``whsec_…`` — store it now. Kept out of ``repr`` so an exception traceback + #: or a debug print of this object cannot spill the signing secret into a log. + secret: str = field(repr=False) + raw: dict[str, Any] | None = field(default=None, compare=False, repr=False) + + @property + def endpoint(self) -> WebhookEndpoint: + """The same endpoint without the secret, safe to hand onward or log.""" + return WebhookEndpoint( + id=self.id, + app_id=self.app_id, + url=self.url, + created_at=self.created_at, + raw=self.raw, + ) + + @classmethod + def _from_api(cls, payload: Any) -> WebhookEndpointWithSecret: + data = _obj(payload, "webhook endpoint") + return cls( + id=_req_str(data, "id"), + app_id=_req_str(data, "appId"), + url=_req_str(data, "url"), + created_at=_req_datetime(data, "createdAt"), + secret=_req_str(data, "secret"), + raw=data, + ) + + +@dataclass(frozen=True, slots=True) +class DeletedWebhookEndpoint: + """The acknowledgement of ``webhook_endpoints.delete``. + + A body rather than a bare 204 so an automated caller can log *what* it removed. + Past deliveries survive as evidence, but a failed one can no longer be + redriven — deleting an endpoint is not reversible. + """ + + id: str + deleted: bool + raw: dict[str, Any] | None = field(default=None, compare=False, repr=False) + + @classmethod + def _from_api(cls, payload: Any) -> DeletedWebhookEndpoint: + data = _obj(payload, "delete result") + return cls(id=_req_str(data, "id"), deleted=_req_bool(data, "deleted"), raw=data) + + +@dataclass(frozen=True, slots=True) +class RotatedSecretKey: + """The result of ``keys.rotate`` — a new secret key, **shown once**. + + :attr:`secret_key` is the only copy that will ever exist. There is no grace + window: the previous key stopped authenticating the instant this response was + produced, so a caller that drops it has locked itself out of the API and must + rotate again from the dashboard. + + :attr:`public_key` is echoed *unchanged* — it identifies the app in the widget + and is not rotated here. Assert on it to prove a CI job rewrote the right app's + secret. + """ + + app_id: str + public_key: str + #: Kept out of ``repr`` for the same reason the webhook secret is: a traceback + #: that prints this object must not put a live credential in your logs. + secret_key: str = field(repr=False) + rotated_at: datetime + raw: dict[str, Any] | None = field(default=None, compare=False, repr=False) + + @classmethod + def _from_api(cls, payload: Any) -> RotatedSecretKey: + data = _obj(payload, "rotated key") + return cls( + app_id=_req_str(data, "appId"), + public_key=_req_str(data, "publicKey"), + secret_key=_req_str(data, "secretKey"), + rotated_at=_req_datetime(data, "rotatedAt"), + raw=data, + ) diff --git a/src/dodomain/resources/connections.py b/src/dodomain/resources/connections.py index ff51858..aa7ef55 100644 --- a/src/dodomain/resources/connections.py +++ b/src/dodomain/resources/connections.py @@ -87,6 +87,26 @@ class Connections: def __init__(self, client: DoDomain) -> None: self._client = client + def get(self, connection_id: str) -> Connection: + """Read one connection by the id every ``connection.*`` webhook carries. + + The body is byte-identical to one element of :meth:`list`, so a single + parser serves both. Reach for this instead of listing-and-filtering when + you already hold an id: past the paging ceiling, filtering means walking + every cursor. + + Unlike :meth:`list`, a **disconnected** connection is returned — a caller + naming an id already knows the row exists, and ``disconnected_at`` is + exactly what it came to read. + + Raises: + NotFoundError: Unknown id — or one you do not own. The two are + deliberately indistinguishable. + """ + return Connection._from_api( + self._client.request(RequestSpec("GET", _connection_path(connection_id))) + ) + def list( self, *, @@ -192,6 +212,12 @@ class AsyncConnections: def __init__(self, client: AsyncDoDomain) -> None: self._client = client + async def get(self, connection_id: str) -> Connection: + """Read one connection by id. See :meth:`Connections.get`.""" + return Connection._from_api( + await self._client.request(RequestSpec("GET", _connection_path(connection_id))) + ) + async def list( self, *, diff --git a/src/dodomain/resources/keys.py b/src/dodomain/resources/keys.py new file mode 100644 index 0000000..31179d5 --- /dev/null +++ b/src/dodomain/resources/keys.py @@ -0,0 +1,72 @@ +"""Credential rotation — the automatable half of key lifecycle. + +One method, and the narrowness is the design. There is no ``keys.create``, +``keys.list`` or ``keys.revoke`` in this SDK because the API has none: key +*inventory* stays behind a human dashboard session on purpose. A stolen ``dd_sk_`` +can already do everything your app can do until it is rotated, but it must not be +able to mint a second, hidden credential that survives you rotating the one you +know about. Self-rotation gives an attacker nothing new and gives you an +automatable kill switch — rotating *is* the revoke. + +**Secret-key only.** An OAuth access token is refused with ``403 forbidden`` and +``details.code == "SECRET_KEY_REQUIRED"``; nothing in the scope grammar covers +credential lifecycle, and an agent token minting app credentials is not a +capability to hand out as a side effect. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from dodomain._transport import RequestSpec +from dodomain.models import RotatedSecretKey + +if TYPE_CHECKING: # pragma: no cover + from dodomain._client import AsyncDoDomain, DoDomain + +__all__ = ["AsyncKeys", "Keys"] + +_ROTATE_SPEC = RequestSpec("POST", "/api/v1/keys/rotate") + + +class Keys: + """``client.keys``""" + + def __init__(self, client: DoDomain) -> None: + self._client = client + + def rotate(self) -> RotatedSecretKey: + """Rotate the calling app's secret key and receive the new one — **once**. + + **There is no grace window.** The key you authenticated this very call with + stopped working the instant the response was produced, and the response is + the only copy of the replacement. Write ``result.secret_key`` to your secret + store *before* you do anything else; a caller that drops it has locked + itself out of the API and must rotate again from the dashboard. + + ``result.public_key`` comes back unchanged — it identifies your app in the + widget and is not rotated here — which is what lets a CI job assert it just + rewrote the right app's secret. + + The client you called this on still holds the **old** key in memory. Build a + new client from ``result.secret_key`` for subsequent calls; this SDK does + not silently re-key a live client, because a rotation you did not notice is + worse than one that fails loudly. + + Raises: + PermissionError_: With + :attr:`~dodomain.errors.PermissionError_.secret_key_required` set, + when called with an OAuth access token rather than a ``dd_sk_`` key. + """ + return RotatedSecretKey._from_api(self._client.request(_ROTATE_SPEC)) + + +class AsyncKeys: + """``client.keys`` on :class:`~dodomain.AsyncDoDomain`.""" + + def __init__(self, client: AsyncDoDomain) -> None: + self._client = client + + async def rotate(self) -> RotatedSecretKey: + """Rotate the calling app's secret key. See :meth:`Keys.rotate`.""" + return RotatedSecretKey._from_api(await self._client.request(_ROTATE_SPEC)) diff --git a/src/dodomain/resources/sessions.py b/src/dodomain/resources/sessions.py index de8f939..5ecb46d 100644 --- a/src/dodomain/resources/sessions.py +++ b/src/dodomain/resources/sessions.py @@ -1,9 +1,15 @@ """Connect sessions — the heart of the API. -``create`` is authenticated with your app secret key. The other three +``create`` and ``get`` are authenticated with your app secret key. The other three (``retrieve``, ``detect``, ``verify``) are **token-public**: the session token in the path *is* the capability, and they are called with no ``Authorization`` header at all, so a browser or a customer-side process can drive the flow. + +``retrieve`` and ``get`` share one server path with two arms, discriminated by the +shape of the segment — a ``dd_sess_`` token gets the public shape, a session id +gets the integrator shape — and this SDK keeps them as two methods rather than one +overloaded call, because they differ in credential, in return type, and in what +they do to an expired session. See :meth:`Sessions.get`. """ from __future__ import annotations @@ -14,13 +20,27 @@ from dodomain._transport import RequestSpec from dodomain._validation import validate_create_session -from dodomain.models import DetectResult, DnsRecord, PublicSession, Session, VerifyResult +from dodomain.errors import InvalidRequestError +from dodomain.models import ( + DetectResult, + DnsRecord, + IntegratorSession, + PublicSession, + Session, + VerifyResult, +) if TYPE_CHECKING: # pragma: no cover from dodomain._client import AsyncDoDomain, DoDomain __all__ = ["AsyncSessions", "Sessions"] +#: The literal prefix every session token is minted with (``SESSION_TOKEN_PREFIX`` +#: in the app repo). Session ids are cuids — ``c`` + base36, never an underscore — +#: so the two key spaces cannot collide, which is exactly why the server can serve +#: both arms off one path. +SESSION_TOKEN_PREFIX = "dd_sess_" + def _token_path(token: str, suffix: str = "") -> str: if not isinstance(token, str) or not token.strip(): @@ -28,6 +48,30 @@ def _token_path(token: str, suffix: str = "") -> str: return f"/api/v1/sessions/{quote(token, safe='')}{suffix}" +def _session_id_path(session_id: str) -> str: + """The authed arm's path, refusing a token before it can silently succeed. + + Passing a token here would *work* — the server would take the token arm and + answer the public shape — and then fail deep inside the parser with a missing + ``appId``. Refusing it up front says the useful thing instead. + """ + if not isinstance(session_id, str) or not session_id.strip(): + raise InvalidRequestError( + "invalid_request", status_code=0, message="a session id is required." + ) + if session_id.startswith(SESSION_TOKEN_PREFIX): + raise InvalidRequestError( + "invalid_request", + status_code=0, + message=( + f"that is a session TOKEN ({SESSION_TOKEN_PREFIX}…), not a session id. " + "Use sessions.retrieve(token) for the token-public shape, or pass the " + "`sessionId` your webhook carried." + ), + ) + return f"/api/v1/sessions/{quote(session_id, safe='')}" + + def _spec_create( *, domain: str, @@ -102,14 +146,47 @@ def create( ) return Session._from_api(self._client.request(spec), base_url=self._client.base_url) + def get(self, session_id: str) -> IntegratorSession: + """Read a session back **by id**, with your credential. + + This is the arm to use from your server, and the only one that answers two + questions :meth:`retrieve` cannot: + + * Webhooks carry ``sessionId``, never the token, so this is how a + ``session.abandoned`` / ``session.completed`` receiver asks what state the + session ended in without having stored the token at creation. + * It reads an **expired** session. :meth:`retrieve` raises + :class:`~dodomain.errors.ExpiredError` forever once the 24h TTL passes, + which is right for a capability URL and useless for support. + + The returned :class:`~dodomain.models.IntegratorSession` is a different + shape from :meth:`retrieve`'s — its ``records`` are composed names with + **no ``value``**, and it adds ``app_id``, ``connection_id`` and ``expired``. + + Raises: + InvalidRequestError: Locally, if handed a ``dd_sess_`` token instead of + an id. + NotFoundError: Unknown id — or one you do not own. + """ + return IntegratorSession._from_api( + self._client.request(RequestSpec("GET", _session_id_path(session_id))) + ) + def retrieve(self, token: str) -> PublicSession: """Read a session back through the token-public route. - No ``Authorization`` header is sent: the token is the capability. + No ``Authorization`` header is sent: the token is the capability. Reading by + id with your own credential is :meth:`get`. Raises: NotFoundError: No such token. ExpiredError: The session's 24h TTL has elapsed. + AuthenticationError: If ``token`` is not ``dd_sess_``-shaped. One server + path serves both arms and picks between them *structurally* off that + prefix, so a malformed value is routed to the authed arm — which this + method sends no credential to. A garbled token therefore answers + **401, not 404**; verified against production, and surprising enough + to be worth knowing before you debug it as an auth problem. """ return PublicSession._from_api( self._client.request(RequestSpec("GET", _token_path(token), auth=False)) @@ -164,6 +241,12 @@ async def create( ) return Session._from_api(await self._client.request(spec), base_url=self._client.base_url) + async def get(self, session_id: str) -> IntegratorSession: + """Read a session back by id, authed. See :meth:`Sessions.get`.""" + return IntegratorSession._from_api( + await self._client.request(RequestSpec("GET", _session_id_path(session_id))) + ) + async def retrieve(self, token: str) -> PublicSession: """Read a session back. See :meth:`Sessions.retrieve`.""" return PublicSession._from_api( diff --git a/src/dodomain/resources/webhook_endpoints.py b/src/dodomain/resources/webhook_endpoints.py new file mode 100644 index 0000000..244c706 --- /dev/null +++ b/src/dodomain/resources/webhook_endpoints.py @@ -0,0 +1,259 @@ +"""Webhook endpoints — where doDomain delivers your events. + +The REST half of what the dashboard's Webhooks card does, so endpoints can be +managed from CI or IaC instead of clicked. + +**Secret-key only.** Every method here needs the app's own ``dd_sk_`` key; an +OAuth access token is refused with ``403 forbidden`` and ``details.code == +"SECRET_KEY_REQUIRED"`` (read it off +:attr:`~dodomain.errors.PermissionError_.secret_key_required`). That is a +deliberate refusal rather than a scope nobody invented: endpoint lifecycle is +credential lifecycle, and an agent token must not be able to repoint your +webhooks. + +**The signing secret is show-once.** ``create`` and ``rotate_secret`` return it; +:meth:`WebhookEndpoints.list` and :meth:`WebhookEndpoints.update` never do, +because a summary that carried a secret would make "list my endpoints" a +disclosure endpoint. + +Ownership is the usual non-enumerating stance: another app's endpoint id and a +nonexistent one both answer **404**. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any +from urllib.parse import quote + +from dodomain._transport import RequestSpec +from dodomain.errors import InvalidRequestError, InvalidResponseError, NotFoundError +from dodomain.models import ( + DeletedWebhookEndpoint, + WebhookEndpoint, + WebhookEndpointWithSecret, +) + +if TYPE_CHECKING: # pragma: no cover + from dodomain._client import AsyncDoDomain, DoDomain + +__all__ = ["AsyncWebhookEndpoints", "WebhookEndpoints"] + +_COLLECTION = "/api/v1/webhook-endpoints" + + +def _require_id(endpoint_id: str) -> str: + if not isinstance(endpoint_id, str) or not endpoint_id.strip(): + raise InvalidRequestError( + "invalid_request", status_code=0, message="a webhook endpoint id is required." + ) + return endpoint_id + + +def _endpoint_path(endpoint_id: str, suffix: str = "") -> str: + return f"{_COLLECTION}/{quote(_require_id(endpoint_id), safe='')}{suffix}" + + +def _validate_url(url: str) -> str: + """Refuse an obviously unusable URL locally; leave the real policy to the API. + + The URL rule (https-only, no localhost or private literals, normalization) has + ONE home, server-side, and a second weaker copy here would drift from it. So + this checks only what cannot possibly be right — an empty or non-string value — + and lets the API answer for everything else with its own message. + """ + if not isinstance(url, str) or not url.strip(): + raise InvalidRequestError( + "invalid_request", status_code=0, message="a webhook endpoint url is required." + ) + return url.strip() + + +def _spec_create(url: str, idempotency_key: str | None) -> RequestSpec: + return RequestSpec( + "POST", _COLLECTION, json_body={"url": _validate_url(url)}, idempotency_key=idempotency_key + ) + + +def _spec_update(endpoint_id: str, url: str, idempotency_key: str | None) -> RequestSpec: + return RequestSpec( + "PATCH", + _endpoint_path(endpoint_id), + json_body={"url": _validate_url(url)}, + idempotency_key=idempotency_key, + ) + + +def _parse_list(payload: Any) -> tuple[WebhookEndpoint, ...]: + items = payload.get("endpoints") if isinstance(payload, dict) else None + if not isinstance(items, list): + raise InvalidResponseError( + "doDomain response: missing or non-array field 'endpoints'", payload=payload + ) + return tuple(WebhookEndpoint._from_api(item) for item in items) + + +def _pick(endpoints: tuple[WebhookEndpoint, ...], endpoint_id: str) -> WebhookEndpoint: + """Find one endpoint in the app's own list, or raise the 404 the API would. + + ``status_code == 0`` marks this as the SDK's own answer: no ``GET + /v1/webhook-endpoints/{id}`` request was ever sent, because the API has no such + route (see :meth:`WebhookEndpoints.get`). + """ + for endpoint in endpoints: + if endpoint.id == endpoint_id: + return endpoint + raise NotFoundError( + "not_found", + status_code=0, + message=( + f"no webhook endpoint {endpoint_id!r} belongs to this app. " + "An id that exists under another app is indistinguishable from one that " + "does not exist at all — the same answer the API gives." + ), + ) + + +class WebhookEndpoints: + """``client.webhook_endpoints``""" + + def __init__(self, client: DoDomain) -> None: + self._client = client + + def list(self) -> tuple[WebhookEndpoint, ...]: + """Every endpoint this app delivers to. Never carries a signing secret.""" + return _parse_list(self._client.request(RequestSpec("GET", _COLLECTION))) + + def get(self, endpoint_id: str) -> WebhookEndpoint: + """One endpoint, by id. + + **This is a client-side lookup over** :meth:`list` **— the API has no + ``GET /v1/webhook-endpoints/{id}`` route.** It therefore costs one list + request, and the :class:`~dodomain.errors.NotFoundError` it raises for an + unknown id carries ``status_code == 0`` because no 404 ever came back from + the server. The answer is the same either way: the list contains exactly + this app's endpoints, so an id missing from it is either someone else's or + nobody's, which is precisely what the API refuses to distinguish. + """ + # Validated BEFORE the list call, not inside the argument list: Python + # evaluates arguments left to right, so `_pick(self.list(), _require_id(x))` + # would spend a request before rejecting an obviously empty id. + wanted = _require_id(endpoint_id) + return _pick(self.list(), wanted) + + def create(self, *, url: str, idempotency_key: str | None = None) -> WebhookEndpointWithSecret: + """Register an endpoint and receive its signing secret — **once**. + + Store ``result.secret`` now: no read surface returns it again, and the only + way to a known secret afterwards is :meth:`rotate_secret`, which invalidates + the old one immediately. + + Raises: + InvalidRequestError: The URL policy rejected it, or this app already + delivers to that URL. + QuotaExceededError: The plan's endpoint cap is spent. + """ + return WebhookEndpointWithSecret._from_api( + self._client.request(_spec_create(url, idempotency_key)) + ) + + def update( + self, endpoint_id: str, *, url: str, idempotency_key: str | None = None + ) -> WebhookEndpoint: + """Repoint an endpoint at a new URL. + + The signing secret is untouched — moving hosts must not force a receiver to + re-key. ``url`` is the only mutable field an endpoint has. + """ + return WebhookEndpoint._from_api( + self._client.request(_spec_update(endpoint_id, url, idempotency_key)) + ) + + def delete( + self, endpoint_id: str, *, idempotency_key: str | None = None + ) -> DeletedWebhookEndpoint: + """Stop delivering to an endpoint. + + Past deliveries survive as history, but a *failed* delivery to a deleted + endpoint can no longer be redriven. This is not reversible. + """ + return DeletedWebhookEndpoint._from_api( + self._client.request( + RequestSpec("DELETE", _endpoint_path(endpoint_id), idempotency_key=idempotency_key) + ) + ) + + def rotate_secret( + self, endpoint_id: str, *, idempotency_key: str | None = None + ) -> WebhookEndpointWithSecret: + """Mint a new signing secret for one endpoint and receive it — **once**. + + **Immediate cutover, no dual-secret window.** The delivery worker reads the + secret live, so signatures switch the moment this returns — including + retries of deliveries created before the rotation. Deploy the new secret to + your receiver before you rotate, or accept a gap of rejected deliveries. + """ + return WebhookEndpointWithSecret._from_api( + self._client.request( + RequestSpec( + "POST", + _endpoint_path(endpoint_id, "/rotate-secret"), + idempotency_key=idempotency_key, + ) + ) + ) + + +class AsyncWebhookEndpoints: + """``client.webhook_endpoints`` on :class:`~dodomain.AsyncDoDomain`.""" + + def __init__(self, client: AsyncDoDomain) -> None: + self._client = client + + async def list(self) -> tuple[WebhookEndpoint, ...]: + """Every endpoint this app delivers to. See :meth:`WebhookEndpoints.list`.""" + return _parse_list(await self._client.request(RequestSpec("GET", _COLLECTION))) + + async def get(self, endpoint_id: str) -> WebhookEndpoint: + """One endpoint, by id. See :meth:`WebhookEndpoints.get`.""" + wanted = _require_id(endpoint_id) + return _pick(await self.list(), wanted) + + async def create( + self, *, url: str, idempotency_key: str | None = None + ) -> WebhookEndpointWithSecret: + """Register an endpoint. See :meth:`WebhookEndpoints.create`.""" + return WebhookEndpointWithSecret._from_api( + await self._client.request(_spec_create(url, idempotency_key)) + ) + + async def update( + self, endpoint_id: str, *, url: str, idempotency_key: str | None = None + ) -> WebhookEndpoint: + """Repoint an endpoint. See :meth:`WebhookEndpoints.update`.""" + return WebhookEndpoint._from_api( + await self._client.request(_spec_update(endpoint_id, url, idempotency_key)) + ) + + async def delete( + self, endpoint_id: str, *, idempotency_key: str | None = None + ) -> DeletedWebhookEndpoint: + """Stop delivering to an endpoint. See :meth:`WebhookEndpoints.delete`.""" + return DeletedWebhookEndpoint._from_api( + await self._client.request( + RequestSpec("DELETE", _endpoint_path(endpoint_id), idempotency_key=idempotency_key) + ) + ) + + async def rotate_secret( + self, endpoint_id: str, *, idempotency_key: str | None = None + ) -> WebhookEndpointWithSecret: + """Mint a new signing secret. See :meth:`WebhookEndpoints.rotate_secret`.""" + return WebhookEndpointWithSecret._from_api( + await self._client.request( + RequestSpec( + "POST", + _endpoint_path(endpoint_id, "/rotate-secret"), + idempotency_key=idempotency_key, + ) + ) + ) diff --git a/src/dodomain/webhooks.py b/src/dodomain/webhooks.py index 6c01f4a..d0210ca 100644 --- a/src/dodomain/webhooks.py +++ b/src/dodomain/webhooks.py @@ -3,18 +3,42 @@ doDomain signs every delivery Stripe-style:: x-dodomain-signature: t=,v1= + x-dodomain-event: connection.verified + x-dodomain-delivery-id: whd_… The signed payload is ``f"{t}.{raw_body}"``, HMAC-SHA256 with your endpoint -secret, lowercase hex. Deliveries also carry ``x-dodomain-event: ``. +secret, lowercase hex. Verify against the **raw** request body, before any JSON parsing: re-serializing a parsed body changes the bytes and the signature will not match. -**No typed event parser ships in this version, on purpose.** The delivered body -is still the legacy ``{event, data}`` shape while the versioned ``{id, type, -occurredAt, data}`` envelope waits on a deliberate cutover, so a typed parser -here would break the day that lands. :func:`verify_webhook` only checks the HMAC -and is wire-format agnostic, which makes it safe across the cutover. +THE WIRE BODY +------------- +The envelope cutover landed on 2026-08-06, so a delivery today looks like:: + + { + "id": "whd_…", # stable across retries — dedupe on this + "type": "connection.verified", + "occurredAt": "2026-08-17T10:00:00.000Z", + "data": {"sessionId": "…", "connectionId": "…", …}, + "event": "connection.verified" # DEPRECATED alias for `type` + } + +``event`` is byte-identical to ``type`` and exists only so receivers written +before the cutover keep parsing. Read ``type``, dedupe on ``id`` — the same +value as the ``x-dodomain-delivery-id`` header, so you can dedupe before parsing +the body at all — and treat ``event`` as legacy. + +``data`` always carries ``sessionId`` as your correlation handle, and every +payload that announces a connection also carries ``connectionId`` — the id +``connections.get`` / ``reverify`` / ``disconnect`` are keyed by. + +**Still no typed event parser, and still on purpose.** The event vocabulary and +the payload fields both grow additively, so a strict parser here would reject a +delivery the day the API adds a type — exactly the failure a webhook receiver +must not have. :func:`verify_webhook` checks the HMAC and nothing else, which is +what makes it safe across every additive change; parse ``json.loads(raw)`` +yourself and ignore what you do not recognise. """ from __future__ import annotations diff --git a/tests/e2e/test_prod_smoke.py b/tests/e2e/test_prod_smoke.py index c00bdad..ce6be63 100644 --- a/tests/e2e/test_prod_smoke.py +++ b/tests/e2e/test_prod_smoke.py @@ -8,7 +8,18 @@ verified, so it expires naturally in 24 hours and the reaper emits ``session.abandoned`` — no production row needs deleting, and ``connections.disconnect`` is never called against a real customer connection. -Budget: roughly eight requests, far under the 60/min plan cap. +Budget: roughly a dozen requests, far under the 60/min plan cap. + +TWO THINGS THIS SUITE DELIBERATELY DOES NOT DO +---------------------------------------------- +``keys.rotate()`` and the webhook-endpoint *writes* are never exercised live. +Rotation has no grace window, so a live call would invalidate the very +``DODOMAIN_SECRET_KEY`` this job authenticates with and break every subsequent CI +run — the response is the only copy of the replacement and nothing here could +store it. Endpoint creation would leave a real delivery target on a production +app. Both are covered by the unit suite against ``respx``; what is proven here +instead is that the routes are deployed and reject an unauthenticated caller, +which is the part a mock cannot tell you. """ from __future__ import annotations @@ -146,12 +157,109 @@ def test_connections_list_returns_a_well_formed_page(client: DoDomain) -> None: assert conn.created_at.tzinfo is not None +def test_the_authed_arm_reads_the_session_back_by_its_id(client: DoDomain, created_session) -> None: + # The id arm, addressed by the same id a webhook would carry — and a genuinely + # different shape from the token arm, which is what this proves against prod. + state = client.sessions.get(created_session.id) + assert state.id == created_session.id + assert state.app_id + # Deliberately NOT pinned to a literal: `created_session` is module-scoped and + # the detect step above persists `status: "detected"` on it, so the value here + # depends on test order. The sibling test below asserts the thing that actually + # matters — that both read arms report the SAME status at the same moment. + assert state.status + assert state.expired is False + assert state.connection_id is None, "an unverified session has no connection yet" + assert state.records, "the authed arm always composes the record names" + assert state.records[0].fqdn.endswith(E2E_DOMAIN_SUFFIX) + # Composed records carry no value — the server omits it on this arm. + assert "value" not in (state.records[0].raw or {}) + + +def test_the_two_session_read_arms_agree_on_the_facts_they_share( + client: DoDomain, created_session +) -> None: + public = client.sessions.retrieve(created_session.token) + authed = client.sessions.get(created_session.id) + assert (authed.id, authed.domain, authed.status) == (public.id, public.domain, public.status) + + +def test_a_session_id_that_is_not_yours_is_a_404(client: DoDomain) -> None: + with pytest.raises(NotFoundError) as excinfo: + client.sessions.get("cthisisnotarealsessionid") + assert excinfo.value.status_code == 404 + + +def test_reading_one_connection_by_id_matches_what_the_list_returned( + client: DoDomain, +) -> None: + page = client.connections.list(limit=1, include_disconnected=True) + if not page.connections: + pytest.skip( + "LOUD SKIP: the e2e app has no connections on prod yet, so connections.get " + "cannot be proven against a real row. The route's deployment is still " + "proven by test_an_unknown_connection_id_is_a_404 below." + ) + listed = page.connections[0] + fetched = client.connections.get(listed.id) + # The route promises a body byte-identical to a list element. + assert fetched == listed + assert isinstance(fetched.record_fqdns, tuple) + + +def test_an_unknown_connection_id_is_a_404(client: DoDomain) -> None: + with pytest.raises(NotFoundError) as excinfo: + client.connections.get("cthisisnotarealconnectionid") + assert excinfo.value.status_code == 404 + + +def test_webhook_endpoints_list_never_returns_a_signing_secret(client: DoDomain) -> None: + for endpoint in client.webhook_endpoints.list(): + assert endpoint.id and endpoint.app_id and endpoint.url + assert endpoint.created_at.tzinfo is not None + assert not hasattr(endpoint, "secret") + assert "secret" not in (endpoint.raw or {}), "a read surface must never carry the secret" + + +def test_the_secret_key_only_routes_are_deployed_and_reject_a_bad_credential() -> None: + # A route that did not exist would answer 404. A 401 proves it is deployed AND + # that it refuses an unusable credential — the only way to touch keys.rotate + # in production without destroying the key this suite runs on. + with DoDomain(secret_key="dd_sk_bogus", base_url=PROD_BASE_URL) as bogus: + with pytest.raises(AuthenticationError) as rotate_error: + bogus.keys.rotate() + with pytest.raises(AuthenticationError) as endpoints_error: + bogus.webhook_endpoints.list() + assert rotate_error.value.status_code == 401 + assert endpoints_error.value.status_code == 401 + + def test_an_unknown_session_token_raises_not_found(client: DoDomain) -> None: + # The value must be dd_sess_-SHAPED. `GET /v1/sessions/:tokenOrId` picks its + # arm structurally off that prefix, so a segment without it is routed to the + # AUTHED arm instead — see the test below, which pins that consequence. with pytest.raises(NotFoundError) as excinfo: - client.sessions.retrieve("definitely-not-a-token") + client.sessions.retrieve("dd_sess_definitely-not-a-real-token") assert excinfo.value.status_code == 404 +def test_a_token_that_is_not_token_shaped_reaches_the_authed_arm_and_401s( + client: DoDomain, +) -> None: + """The consequence of one path serving two arms, pinned against the real API. + + Regression guard: this suite used to pass a bare ``"definitely-not-a-token"`` + to ``retrieve`` and expect 404. It was correct until the API grew the + integrator-authed id arm, after which that segment stops looking like a token, + is routed to the arm that requires a credential, and — because ``retrieve`` + sends none — answers 401. Nothing in the SDK changed; the API's behavior did, + and only a live run could tell us. + """ + with pytest.raises(AuthenticationError) as excinfo: + client.sessions.retrieve("definitely-not-a-token") + assert excinfo.value.status_code == 401 + + def test_a_bogus_secret_key_raises_an_authentication_error() -> None: with ( DoDomain(secret_key="dd_sk_bogus", base_url=PROD_BASE_URL) as bogus, diff --git a/tests/helpers.py b/tests/helpers.py index 1dfe7bb..4ce6c21 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -48,6 +48,34 @@ def make_async_client(**kwargs: Any) -> AsyncDoDomain: "token": "tok_live_abc123", "expiresAt": "2026-08-06T12:00:00.000Z", "connectUrl": "https://app.dodomain.io/connect/tok_live_abc123", + # `records` is required on the wire today; it and `warnings` arrived after the + # SDK's first release, which is why LEGACY_CREATE_SESSION_RESPONSE below still + # has to parse. + "records": [{"type": "CNAME", "host": "app", "fqdn": "app.app.customer.com"}], +} + +#: A create response exactly as the API shipped it before `records`/`warnings` +#: existed — the shape a cached or archived payload still has. +LEGACY_CREATE_SESSION_RESPONSE: dict[str, Any] = { + key: value for key, value in CREATE_SESSION_RESPONSE.items() if key != "records" +} + +#: The authed-by-id read (`sessions.get`) — a DIFFERENT shape from the +#: token-public one: composed records with no `value`, plus appId/connectionId/ +#: expired and no returnUrl. +INTEGRATOR_SESSION_RESPONSE: dict[str, Any] = { + "id": "cs_01HZX", + "appId": "app_1", + "domain": "app.customer.com", + "records": [{"type": "CNAME", "host": "app", "fqdn": "app.app.customer.com"}], + "recipe": None, + "status": "verified", + "tier": 2, + "detectedProvider": "Cloudflare", + "connectionId": "conn_1", + "createdAt": "2026-08-05T12:00:00.000Z", + "expiresAt": "2026-08-06T12:00:00.000Z", + "expired": False, } PUBLIC_SESSION_RESPONSE: dict[str, Any] = { @@ -133,7 +161,11 @@ def connection(**overrides: Any) -> dict[str, Any]: "appId": "app_1", "sessionId": "cs_01HZX", "domain": "app.customer.com", + # `fqdn` really is the session DOMAIN on the wire, not a record name — + # the field the API froze rather than repaired. `recordFqdns` carries the + # names actually monitored, which is why the two differ here on purpose. "fqdn": "app.customer.com", + "recordFqdns": ["status.app.customer.com"], "status": "active", "verifiedAt": "2026-08-01T10:00:00.000Z", "lastCheckedAt": "2026-08-05T10:00:00.000Z", @@ -145,6 +177,30 @@ def connection(**overrides: Any) -> dict[str, Any]: return payload +def webhook_endpoint(**overrides: Any) -> dict[str, Any]: + payload: dict[str, Any] = { + "id": "whe_1", + "appId": "app_1", + "url": "https://acme.example/webhooks/dodomain", + "createdAt": "2026-08-01T09:00:00.000Z", + } + payload.update(overrides) + return payload + + +def webhook_endpoint_with_secret(**overrides: Any) -> dict[str, Any]: + overrides.setdefault("secret", "whsec_shown_once") + return webhook_endpoint(**overrides) + + +ROTATED_KEY_RESPONSE: dict[str, Any] = { + "appId": "app_1", + "publicKey": "dd_pk_live_abc", + "secretKey": "dd_sk_live_the_new_one", + "rotatedAt": "2026-08-17T12:00:00.000Z", +} + + LIST_APPS_RESPONSE: dict[str, Any] = { "apps": [ { diff --git a/tests/test_async_parity.py b/tests/test_async_parity.py index de75639..bb886fa 100644 --- a/tests/test_async_parity.py +++ b/tests/test_async_parity.py @@ -21,13 +21,17 @@ CREATE_SESSION_RESPONSE, DETECT_RESPONSE, DISCONNECT_RESPONSE, + INTEGRATOR_SESSION_RESPONSE, LIST_APPS_RESPONSE, PUBLIC_SESSION_RESPONSE, + ROTATED_KEY_RESPONSE, VERIFY_RESPONSE, api, connection, make_async_client, make_client, + webhook_endpoint, + webhook_endpoint_with_secret, ) CNAME = DnsRecord(type="CNAME", host="app", value="cname.dodomain.io") @@ -138,6 +142,105 @@ lambda c: c.apps.list(), lambda c: c.apps.list(), ), + ( + "sessions.get", + lambda: ( + respx.get(api("/api/v1/sessions/cs_01HZX")).mock( + return_value=httpx.Response(200, json=INTEGRATOR_SESSION_RESPONSE) + ) + and None + ), + lambda c: c.sessions.get("cs_01HZX"), + lambda c: c.sessions.get("cs_01HZX"), + ), + ( + "connections.get", + lambda: ( + respx.get(api("/api/v1/connections/conn_1")).mock( + return_value=httpx.Response(200, json=connection()) + ) + and None + ), + lambda c: c.connections.get("conn_1"), + lambda c: c.connections.get("conn_1"), + ), + ( + "webhook_endpoints.list", + lambda: ( + respx.get(api("/api/v1/webhook-endpoints")).mock( + return_value=httpx.Response(200, json={"endpoints": [webhook_endpoint()]}) + ) + and None + ), + lambda c: c.webhook_endpoints.list(), + lambda c: c.webhook_endpoints.list(), + ), + ( + "webhook_endpoints.get", + lambda: ( + respx.get(api("/api/v1/webhook-endpoints")).mock( + return_value=httpx.Response(200, json={"endpoints": [webhook_endpoint()]}) + ) + and None + ), + lambda c: c.webhook_endpoints.get("whe_1"), + lambda c: c.webhook_endpoints.get("whe_1"), + ), + ( + "webhook_endpoints.create", + lambda: ( + respx.post(api("/api/v1/webhook-endpoints")).mock( + return_value=httpx.Response(201, json=webhook_endpoint_with_secret()) + ) + and None + ), + lambda c: c.webhook_endpoints.create(url="https://acme.example/hook"), + lambda c: c.webhook_endpoints.create(url="https://acme.example/hook"), + ), + ( + "webhook_endpoints.update", + lambda: ( + respx.patch(api("/api/v1/webhook-endpoints/whe_1")).mock( + return_value=httpx.Response(200, json=webhook_endpoint(url="https://a.example/v2")) + ) + and None + ), + lambda c: c.webhook_endpoints.update("whe_1", url="https://a.example/v2"), + lambda c: c.webhook_endpoints.update("whe_1", url="https://a.example/v2"), + ), + ( + "webhook_endpoints.delete", + lambda: ( + respx.delete(api("/api/v1/webhook-endpoints/whe_1")).mock( + return_value=httpx.Response(200, json={"id": "whe_1", "deleted": True}) + ) + and None + ), + lambda c: c.webhook_endpoints.delete("whe_1"), + lambda c: c.webhook_endpoints.delete("whe_1"), + ), + ( + "webhook_endpoints.rotate_secret", + lambda: ( + respx.post(api("/api/v1/webhook-endpoints/whe_1/rotate-secret")).mock( + return_value=httpx.Response(200, json=webhook_endpoint_with_secret()) + ) + and None + ), + lambda c: c.webhook_endpoints.rotate_secret("whe_1"), + lambda c: c.webhook_endpoints.rotate_secret("whe_1"), + ), + ( + "keys.rotate", + lambda: ( + respx.post(api("/api/v1/keys/rotate")).mock( + return_value=httpx.Response(200, json=ROTATED_KEY_RESPONSE) + ) + and None + ), + lambda c: c.keys.rotate(), + lambda c: c.keys.rotate(), + ), ] @@ -195,6 +298,44 @@ async def test_the_async_client_sends_no_credential_on_token_public_routes() -> assert "authorization" not in route.calls[0].request.headers +def test_both_clients_expose_the_same_resources_with_the_same_method_names() -> None: + """The structural guard the case list above cannot give. + + A parametrized case only proves the methods someone remembered to add a case + for. This proves the two trees are the same shape, so a resource or method + added to one client and forgotten on the other fails here immediately. + """ + sync_client = DoDomain(secret_key="dd_sk_test_key") + async_client = AsyncDoDomain(secret_key="dd_sk_test_key") + + def resources(client: object) -> dict[str, set[str]]: + names = { + name + for name in vars(client) + if not name.startswith("_") and hasattr(getattr(client, name), "__class__") + } + return { + name: { + method + for method in dir(getattr(client, name)) + if not method.startswith("_") and callable(getattr(getattr(client, name), method)) + } + for name in names + if type(getattr(client, name)).__module__.startswith("dodomain.resources") + } + + assert resources(async_client) == resources(sync_client) + # And the tree is non-trivial, so an empty-vs-empty comparison cannot pass. + assert set(resources(sync_client)) == { + "apps", + "connections", + "domains", + "keys", + "sessions", + "webhook_endpoints", + } + + def test_no_sync_httpx_client_is_ever_constructed_on_the_async_path( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/test_connections.py b/tests/test_connections.py index a2d9d71..506683d 100644 --- a/tests/test_connections.py +++ b/tests/test_connections.py @@ -6,7 +6,7 @@ import pytest import respx -from dodomain import InvalidRequestError, NotFoundError +from dodomain import InvalidRequestError, InvalidResponseError, NotFoundError from tests.helpers import DISCONNECT_RESPONSE, TEST_JWT, api, connection, make_client @@ -31,6 +31,114 @@ def test_list_sends_every_documented_query_parameter() -> None: assert params["includeDisconnected"] == "true" +@respx.mock +def test_get_reads_one_connection_by_the_id_webhooks_carry() -> None: + route = respx.get(api("/api/v1/connections/conn_1")).mock( + return_value=httpx.Response(200, json=connection()) + ) + with make_client() as client: + conn = client.connections.get("conn_1") + assert route.calls[0].request.method == "GET" + assert conn.id == "conn_1" + assert conn.session_id == "cs_01HZX" + assert conn.status == "active" + + +@respx.mock +def test_get_returns_the_same_shape_as_one_element_of_the_list() -> None: + # The route promises a body byte-identical to a list element, so one parser + # must serve both — if it ever stops being true, this fails. + respx.get(api("/api/v1/connections")).mock( + return_value=httpx.Response(200, json={"connections": [connection()], "nextCursor": None}) + ) + respx.get(api("/api/v1/connections/conn_1")).mock( + return_value=httpx.Response(200, json=connection()) + ) + with make_client() as client: + from_list = client.connections.list().connections[0] + from_get = client.connections.get("conn_1") + assert from_get == from_list + + +@respx.mock +def test_get_returns_a_disconnected_connection_which_list_hides_by_default() -> None: + respx.get(api("/api/v1/connections/conn_1")).mock( + return_value=httpx.Response(200, json=connection(disconnectedAt="2026-08-05T12:00:00.000Z")) + ) + with make_client() as client: + conn = client.connections.get("conn_1") + assert conn.disconnected_at == datetime(2026, 8, 5, 12, 0, tzinfo=timezone.utc) + + +@respx.mock +def test_get_on_a_connection_you_do_not_own_is_a_404_not_a_403() -> None: + respx.get(api("/api/v1/connections/conn_x")).mock( + return_value=httpx.Response(404, json={"error": "not_found"}) + ) + with pytest.raises(NotFoundError), make_client() as client: + client.connections.get("conn_x") + + +def test_get_refuses_an_empty_connection_id_locally() -> None: + with pytest.raises(InvalidRequestError), make_client() as client: + client.connections.get("") + + +# ── recordFqdns: the names actually monitored ─────────────────────────────── + + +@respx.mock +def test_record_fqdns_carries_the_monitored_names_which_fqdn_does_not() -> None: + # `fqdn` is the session DOMAIN, frozen that way on purpose. A caller reading it + # as a record name gets the wrong answer, which is why recordFqdns exists. + respx.get(api("/api/v1/connections/conn_1")).mock( + return_value=httpx.Response(200, json=connection()) + ) + with make_client() as client: + conn = client.connections.get("conn_1") + assert conn.fqdn == "app.customer.com" + assert conn.record_fqdns == ("status.app.customer.com",) + + +@respx.mock +def test_a_connection_recorded_before_record_fqdns_existed_still_parses() -> None: + legacy = connection() + del legacy["recordFqdns"] + respx.get(api("/api/v1/connections")).mock( + return_value=httpx.Response(200, json={"connections": [legacy], "nextCursor": None}) + ) + with make_client() as client: + conn = client.connections.list().connections[0] + assert conn.record_fqdns == () + assert conn.id == "conn_1" + + +@respx.mock +def test_a_session_with_several_records_reports_every_monitored_name() -> None: + respx.get(api("/api/v1/connections")).mock( + return_value=httpx.Response( + 200, + json={ + "connections": [connection(recordFqdns=["app.customer.com", "_acme.customer.com"])], + "nextCursor": None, + }, + ) + ) + with make_client() as client: + conn = client.connections.list().connections[0] + assert conn.record_fqdns == ("app.customer.com", "_acme.customer.com") + + +@respx.mock +def test_a_non_array_record_fqdns_is_loud_rather_than_silently_empty() -> None: + # Absence is history; a present-but-wrong type is drift, and drift must fail. + respx.get(api("/api/v1/connections/conn_1")).mock( + return_value=httpx.Response(200, json=connection(recordFqdns="app.customer.com")) + ) + with pytest.raises(InvalidResponseError), make_client() as client: + client.connections.get("conn_1") + + @respx.mock def test_list_defaults_to_live_connections_only() -> None: route = respx.get(api("/api/v1/connections")).mock( diff --git a/tests/test_keys.py b/tests/test_keys.py new file mode 100644 index 0000000..58d3dc7 --- /dev/null +++ b/tests/test_keys.py @@ -0,0 +1,97 @@ +"""``keys.rotate`` — the one credential-lifecycle call the API exposes.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import httpx +import pytest +import respx + +from dodomain import PermissionError_ +from tests.helpers import ROTATED_KEY_RESPONSE, TEST_JWT, api, make_client + +ROTATE = api("/api/v1/keys/rotate") + + +@respx.mock +def test_rotate_posts_and_returns_the_new_secret_key() -> None: + route = respx.post(ROTATE).mock(return_value=httpx.Response(200, json=ROTATED_KEY_RESPONSE)) + with make_client() as client: + rotated = client.keys.rotate() + assert route.calls[0].request.method == "POST" + assert rotated.app_id == "app_1" + assert rotated.secret_key == "dd_sk_live_the_new_one" + assert rotated.rotated_at == datetime(2026, 8, 17, 12, 0, tzinfo=timezone.utc) + + +@respx.mock +def test_the_public_key_comes_back_unchanged_so_ci_can_assert_which_app_it_rewrote() -> None: + respx.post(ROTATE).mock(return_value=httpx.Response(200, json=ROTATED_KEY_RESPONSE)) + with make_client() as client: + rotated = client.keys.rotate() + assert rotated.public_key == "dd_pk_live_abc" + + +@respx.mock +def test_rotate_sends_a_body_less_post_with_the_callers_own_credential() -> None: + # The app is implicit in the key — there is nothing to send, and nothing that + # would let a caller name a DIFFERENT app to rotate. + route = respx.post(ROTATE).mock(return_value=httpx.Response(200, json=ROTATED_KEY_RESPONSE)) + with make_client() as client: + client.keys.rotate() + request = route.calls[0].request + assert request.content == b"" + assert request.headers["authorization"] == "Bearer dd_sk_test_0123456789" + + +@respx.mock +def test_the_new_secret_key_stays_out_of_the_repr() -> None: + respx.post(ROTATE).mock(return_value=httpx.Response(200, json=ROTATED_KEY_RESPONSE)) + with make_client() as client: + rotated = client.keys.rotate() + assert "dd_sk_live_the_new_one" not in repr(rotated) + assert rotated.secret_key == "dd_sk_live_the_new_one" + + +@respx.mock +def test_an_oauth_caller_is_refused_with_the_secret_key_required_signal() -> None: + route = respx.post(ROTATE).mock( + return_value=httpx.Response( + 403, + json={ + "error": "forbidden", + "message": "this endpoint requires the app's dd_sk_ secret key", + "details": {"code": "SECRET_KEY_REQUIRED"}, + }, + ) + ) + with pytest.raises(PermissionError_) as excinfo, make_client(secret_key=TEST_JWT) as client: + client.keys.rotate() + assert excinfo.value.status_code == 403 + assert excinfo.value.secret_key_required is True + # No scope fixes this, so the SDK must not report one. + assert excinfo.value.required_scope is None + assert route.call_count == 1 + + +@respx.mock +def test_the_client_keeps_using_the_old_key_until_the_caller_rebuilds_it() -> None: + # Silently re-keying a live client would hide a rotation the caller must act + # on; the next call deliberately still carries the key it was constructed with. + respx.post(ROTATE).mock(return_value=httpx.Response(200, json=ROTATED_KEY_RESPONSE)) + apps = respx.get(api("/api/v1/apps")).mock(return_value=httpx.Response(200, json={"apps": []})) + with make_client() as client: + client.keys.rotate() + client.apps.list() + assert apps.calls[0].request.headers["authorization"] == "Bearer dd_sk_test_0123456789" + + +@respx.mock +def test_a_rotate_response_missing_the_secret_key_is_an_invalid_response() -> None: + from dodomain import InvalidResponseError + + body = {key: value for key, value in ROTATED_KEY_RESPONSE.items() if key != "secretKey"} + respx.post(ROTATE).mock(return_value=httpx.Response(200, json=body)) + with pytest.raises(InvalidResponseError), make_client() as client: + client.keys.rotate() diff --git a/tests/test_readme_examples.py b/tests/test_readme_examples.py index 9ab73e0..6e1512a 100644 --- a/tests/test_readme_examples.py +++ b/tests/test_readme_examples.py @@ -22,13 +22,17 @@ CREATE_SESSION_RESPONSE, DETECT_RESPONSE, DISCONNECT_RESPONSE, + INTEGRATOR_SESSION_RESPONSE, LIST_APPS_RESPONSE, PUBLIC_SESSION_RESPONSE, + ROTATED_KEY_RESPONSE, VERIFY_RESPONSE, api, connection, make_async_client, make_client, + webhook_endpoint, + webhook_endpoint_with_secret, ) README = pathlib.Path(__file__).resolve().parent.parent / "README.md" @@ -65,6 +69,8 @@ def test_the_quickstart_block_runs() -> None: ) assert session.connect_url.startswith("https://app.dodomain.io/connect/") assert session.expires_at.tzinfo is not None + # The block prints the composed name — the doubled-label check. + assert [r.fqdn for r in session.records] == ["app.app.customer.com"] @respx.mock @@ -147,6 +153,71 @@ def test_the_connections_block_runs() -> None: assert result.disconnected_at is not None +@respx.mock +def test_the_session_read_back_block_runs() -> None: + respx.get(api("/api/v1/sessions/cs_01HZX")).mock( + return_value=httpx.Response(200, json=INTEGRATOR_SESSION_RESPONSE) + ) + with make_client() as client: + state = client.sessions.get("cs_01HZX") + assert state.status == "verified" + assert state.expired is False + assert state.connection_id == "conn_1" + assert state.records[0].fqdn == "app.app.customer.com" + + +@respx.mock +def test_the_webhook_endpoints_block_runs() -> None: + respx.post(api("/api/v1/webhook-endpoints")).mock( + return_value=httpx.Response(201, json=webhook_endpoint_with_secret()) + ) + respx.get(api("/api/v1/webhook-endpoints")).mock( + return_value=httpx.Response(200, json={"endpoints": [webhook_endpoint()]}) + ) + respx.patch(api("/api/v1/webhook-endpoints/whe_123")).mock( + return_value=httpx.Response(200, json=webhook_endpoint(id="whe_123")) + ) + respx.post(api("/api/v1/webhook-endpoints/whe_123/rotate-secret")).mock( + return_value=httpx.Response(200, json=webhook_endpoint_with_secret(id="whe_123")) + ) + respx.delete(api("/api/v1/webhook-endpoints/whe_123")).mock( + return_value=httpx.Response(200, json={"id": "whe_123", "deleted": True}) + ) + with make_client() as client: + endpoint = client.webhook_endpoints.create(url="https://acme.example/webhooks/dodomain") + assert endpoint.secret.startswith("whsec_") + assert [(e.id, e.url) for e in client.webhook_endpoints.list()] == [ + ("whe_1", "https://acme.example/webhooks/dodomain") + ] + client.webhook_endpoints.update("whe_123", url="https://acme.example/v2") + rotated = client.webhook_endpoints.rotate_secret("whe_123") + assert rotated.secret.startswith("whsec_") + assert client.webhook_endpoints.delete("whe_123").deleted is True + + +@respx.mock +def test_the_key_rotation_block_runs() -> None: + respx.post(api("/api/v1/keys/rotate")).mock( + return_value=httpx.Response(200, json=ROTATED_KEY_RESPONSE) + ) + with make_client() as client: + rotated = client.keys.rotate() + assert rotated.secret_key.startswith("dd_sk_") + assert rotated.public_key == "dd_pk_live_abc" + + +@respx.mock +def test_the_connection_get_lines_in_the_connections_block_run() -> None: + respx.get(api("/api/v1/connections/conn_123")).mock( + return_value=httpx.Response(200, json=connection(id="conn_123")) + ) + with make_client() as client: + conn = client.connections.get("conn_123") + assert conn.status in ("active", "broken") + assert conn.record_fqdns == ("status.app.customer.com",) + assert conn.disconnected_at is None + + @respx.mock def test_the_domain_check_and_apps_blocks_run() -> None: respx.post(api("/api/v1/domains/check")).mock( @@ -168,17 +239,46 @@ def test_the_domain_check_and_apps_blocks_run() -> None: ) -def test_the_webhook_handler_block_runs() -> None: +def test_the_webhook_handler_block_runs_against_the_body_actually_delivered() -> None: secret = "whsec_readme" - raw = json.dumps({"event": "connection.verified", "data": {"domain": "app.customer.com"}}) + # The post-cutover envelope the README documents, key order and all. + raw = json.dumps( + { + "id": "whd_1", + "type": "connection.verified", + "occurredAt": "2026-08-17T10:00:00.000Z", + "data": {"sessionId": "cs_01HZX", "connectionId": "conn_1"}, + "event": "connection.verified", + } + ) header = sign_webhook(secret, raw, 1786000000000) assert verify_webhook(secret, raw, header, now_ms=1786000000000) is True - assert json.loads(raw)["event"] == "connection.verified" + event = json.loads(raw) + assert event["type"] == "connection.verified" + # The deprecated alias is byte-identical to `type`, which is the whole reason + # a pre-cutover receiver keeps working. + assert event["event"] == event["type"] + assert event["data"]["connectionId"] == "conn_1" # The rejection path the README shows. assert verify_webhook(secret, raw, "garbage", now_ms=1786000000000) is False +def test_the_readme_no_longer_claims_the_legacy_webhook_body_is_current() -> None: + # 0.1.0 documented `{event, data}` as what arrives. The cutover landed on + # 2026-08-06 and a reader following the old text would build the wrong parser. + text = README.read_text(encoding="utf-8") + assert "still the legacy" not in text + assert "waits on a deliberate cutover" not in text + assert "occurredAt" in text + + +def test_the_readme_does_not_repeat_the_false_no_openapi_claim() -> None: + # The spec is served at dodomain.io/docs/openapi.json; saying otherwise sent + # readers looking for a contract that was published all along. + assert "publishes no\nOpenAPI" not in README.read_text(encoding="utf-8") + + @respx.mock def test_the_client_options_block_runs() -> None: respx.get(api("/api/v1/apps")).mock(return_value=httpx.Response(200, json=LIST_APPS_RESPONSE)) diff --git a/tests/test_sessions.py b/tests/test_sessions.py index e2e38ef..c8316b2 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -11,6 +11,8 @@ from tests.helpers import ( CREATE_SESSION_RESPONSE, DETECT_RESPONSE, + INTEGRATOR_SESSION_RESPONSE, + LEGACY_CREATE_SESSION_RESPONSE, PUBLIC_SESSION_RESPONSE, TEST_JWT, TEST_KEY, @@ -234,6 +236,178 @@ def test_create_forwards_an_idempotency_key_even_though_the_api_ignores_it() -> assert route.calls[0].request.headers["idempotency-key"] == "abc-123" +# ── the composed names a create answers with ──────────────────────────────── + + +@respx.mock +def test_create_reports_the_composed_names_the_session_will_be_verified_at() -> None: + # The doubled-label trap: domain "app.customer.com" + host "app" is monitored + # at "app.app.customer.com", and this is the only place a caller sees that + # before a verify fails. + respx.post(api("/api/v1/sessions")).mock( + return_value=httpx.Response(200, json=CREATE_SESSION_RESPONSE) + ) + with make_client() as client: + session = client.sessions.create(domain="app.customer.com", records=[CNAME]) + assert len(session.records) == 1 + assert session.records[0].fqdn == "app.app.customer.com" + assert session.records[0].type == "CNAME" + assert session.records[0].host == "app" + assert not hasattr(session.records[0], "value") + + +@respx.mock +def test_create_surfaces_a_warning_on_an_otherwise_accepted_session() -> None: + respx.post(api("/api/v1/sessions")).mock( + return_value=httpx.Response( + 200, + json={ + **CREATE_SESSION_RESPONSE, + "warnings": [ + { + "code": "duplicate_host_label", + "message": "host 'app' repeats the domain's first label", + "host": "app", + "fqdn": "app.app.customer.com", + } + ], + }, + ) + ) + with make_client() as client: + session = client.sessions.create(domain="app.customer.com", records=[CNAME]) + # A warning is advisory: the session was created, and nothing raised. + assert session.id == "cs_01HZX" + assert session.warnings[0].code == "duplicate_host_label" + assert session.warnings[0].fqdn == "app.app.customer.com" + + +@respx.mock +def test_a_clean_create_reports_no_warnings_rather_than_none() -> None: + respx.post(api("/api/v1/sessions")).mock( + return_value=httpx.Response(200, json=CREATE_SESSION_RESPONSE) + ) + with make_client() as client: + assert client.sessions.create(domain="app.customer.com", records=[CNAME]).warnings == () + + +@respx.mock +def test_a_create_response_from_before_these_fields_existed_still_parses() -> None: + respx.post(api("/api/v1/sessions")).mock( + return_value=httpx.Response(200, json=LEGACY_CREATE_SESSION_RESPONSE) + ) + with make_client() as client: + session = client.sessions.create(domain="app.customer.com", records=[CNAME]) + assert session.records == () + assert session.connect_url.startswith("https://app.dodomain.io/connect/") + + +# ── the authed read-by-id arm ─────────────────────────────────────────────── + + +@respx.mock +def test_get_reads_a_session_by_id_with_the_credential_attached() -> None: + route = respx.get(api("/api/v1/sessions/cs_01HZX")).mock( + return_value=httpx.Response(200, json=INTEGRATOR_SESSION_RESPONSE) + ) + with make_client() as client: + session = client.sessions.get("cs_01HZX") + assert route.calls[0].request.headers["authorization"] == f"Bearer {TEST_KEY}" + assert session.id == "cs_01HZX" + assert session.app_id == "app_1" + assert session.connection_id == "conn_1" + assert session.status == "verified" + assert session.created_at == datetime(2026, 8, 5, 12, 0, tzinfo=timezone.utc) + + +@respx.mock +def test_the_authed_arm_answers_composed_records_that_carry_no_value() -> None: + # zComposedRecord picks only type/host off the record schema — the value is + # deliberately absent, because these are the names monitored, not the contents. + respx.get(api("/api/v1/sessions/cs_01HZX")).mock( + return_value=httpx.Response(200, json=INTEGRATOR_SESSION_RESPONSE) + ) + with make_client() as client: + records = client.sessions.get("cs_01HZX").records + assert records[0].fqdn == "app.app.customer.com" + assert not hasattr(records[0], "value") + assert "value" not in (records[0].raw or {}) + + +@respx.mock +def test_get_reads_an_expired_session_where_retrieve_would_raise_410() -> None: + # The whole reason this arm exists: the moment you most want the final state + # is after the session died, and the token route answers 410 forever. + respx.get(api("/api/v1/sessions/cs_dead")).mock( + return_value=httpx.Response( + 200, json={**INTEGRATOR_SESSION_RESPONSE, "id": "cs_dead", "expired": True} + ) + ) + with make_client() as client: + session = client.sessions.get("cs_dead") + assert session.expired is True + + +@respx.mock +def test_expired_is_trusted_over_a_status_the_reaper_has_not_caught_up_with() -> None: + respx.get(api("/api/v1/sessions/cs_01HZX")).mock( + return_value=httpx.Response( + 200, json={**INTEGRATOR_SESSION_RESPONSE, "status": "pending", "expired": True} + ) + ) + with make_client() as client: + session = client.sessions.get("cs_01HZX") + assert (session.status, session.expired) == ("pending", True) + + +@respx.mock +def test_get_reports_no_connection_id_until_the_session_finalizes() -> None: + respx.get(api("/api/v1/sessions/cs_01HZX")).mock( + return_value=httpx.Response(200, json={**INTEGRATOR_SESSION_RESPONSE, "connectionId": None}) + ) + with make_client() as client: + assert client.sessions.get("cs_01HZX").connection_id is None + + +@respx.mock +def test_get_on_a_session_you_do_not_own_is_a_404() -> None: + from dodomain import NotFoundError + + respx.get(api("/api/v1/sessions/cs_someone_else")).mock( + return_value=httpx.Response(404, json={"error": "not_found"}) + ) + with pytest.raises(NotFoundError), make_client() as client: + client.sessions.get("cs_someone_else") + + +@respx.mock +def test_get_refuses_a_session_token_locally_instead_of_reading_the_wrong_arm() -> None: + # Handing a token to the authed arm WOULD succeed server-side and answer the + # public shape, then fail deep in the parser on a missing appId. Say so early. + route = respx.get(api("/api/v1/sessions/dd_sess_abc")).mock( + return_value=httpx.Response(200, json=PUBLIC_SESSION_RESPONSE) + ) + with pytest.raises(InvalidRequestError) as excinfo, make_client() as client: + client.sessions.get("dd_sess_abc") + assert route.call_count == 0 + assert "sessions.retrieve" in str(excinfo.value) + + +def test_get_refuses_an_empty_session_id_locally() -> None: + with pytest.raises(InvalidRequestError), make_client() as client: + client.sessions.get(" ") + + +@respx.mock +def test_a_session_id_is_url_encoded_into_the_authed_path() -> None: + route = respx.get(api("/api/v1/sessions/cs%2F1")).mock( + return_value=httpx.Response(200, json=INTEGRATOR_SESSION_RESPONSE) + ) + with make_client() as client: + client.sessions.get("cs/1") + assert route.call_count == 1 + + # ── token-public reads ────────────────────────────────────────────────────── diff --git a/tests/test_version.py b/tests/test_version.py index da7d385..1ac0cd8 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -4,4 +4,12 @@ def test_version_is_the_single_source_of_truth() -> None: - assert dodomain.__version__ == "0.1.0" + assert dodomain.__version__ == "0.2.0" + + +def test_the_user_agent_reports_that_same_version() -> None: + # `hatch` reads the version out of __init__.py and the client stamps it onto + # every request, so a bump that misses one of the two is invisible until a + # support conversation about "which SDK version sent this". + client = dodomain.DoDomain(secret_key="dd_sk_test_key") + assert client.user_agent == f"dodomain-python/{dodomain.__version__}" diff --git a/tests/test_webhook_endpoints.py b/tests/test_webhook_endpoints.py new file mode 100644 index 0000000..e72c103 --- /dev/null +++ b/tests/test_webhook_endpoints.py @@ -0,0 +1,274 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +import httpx +import pytest +import respx + +from dodomain import ( + InvalidRequestError, + InvalidResponseError, + NotFoundError, + PermissionError_, + WebhookEndpoint, + WebhookEndpointWithSecret, +) +from tests.helpers import ( + api, + make_client, + webhook_endpoint, + webhook_endpoint_with_secret, +) + +COLLECTION = api("/api/v1/webhook-endpoints") + + +@respx.mock +def test_list_parses_every_endpoint_field() -> None: + respx.get(COLLECTION).mock( + return_value=httpx.Response(200, json={"endpoints": [webhook_endpoint()]}) + ) + with make_client() as client: + endpoints = client.webhook_endpoints.list() + assert len(endpoints) == 1 + assert endpoints[0].id == "whe_1" + assert endpoints[0].app_id == "app_1" + assert endpoints[0].url == "https://acme.example/webhooks/dodomain" + assert endpoints[0].created_at == datetime(2026, 8, 1, 9, 0, tzinfo=timezone.utc) + + +@respx.mock +def test_list_never_yields_an_object_carrying_a_signing_secret() -> None: + # The summary schema omits `secret` server-side. If a future deployment ever + # leaked one into the list body, the SDK must still not surface it as a + # secret-bearing type that a caller might log or persist. + respx.get(COLLECTION).mock( + return_value=httpx.Response(200, json={"endpoints": [webhook_endpoint_with_secret()]}) + ) + with make_client() as client: + endpoint = client.webhook_endpoints.list()[0] + assert isinstance(endpoint, WebhookEndpoint) + assert not isinstance(endpoint, WebhookEndpointWithSecret) + assert not hasattr(endpoint, "secret") + + +@respx.mock +def test_a_list_body_without_the_endpoints_array_is_an_invalid_response() -> None: + respx.get(COLLECTION).mock(return_value=httpx.Response(200, json={"data": []})) + with pytest.raises(InvalidResponseError), make_client() as client: + client.webhook_endpoints.list() + + +@respx.mock +def test_create_returns_the_show_once_secret_and_accepts_a_201() -> None: + route = respx.post(COLLECTION).mock( + return_value=httpx.Response(201, json=webhook_endpoint_with_secret()) + ) + with make_client() as client: + created = client.webhook_endpoints.create(url="https://acme.example/webhooks/dodomain") + assert created.secret == "whsec_shown_once" + assert created.id == "whe_1" + assert route.calls[0].request.method == "POST" + + +@respx.mock +def test_create_sends_the_url_as_the_whole_body() -> None: + import json + + route = respx.post(COLLECTION).mock( + return_value=httpx.Response(201, json=webhook_endpoint_with_secret()) + ) + with make_client() as client: + client.webhook_endpoints.create(url=" https://acme.example/hook ") + assert json.loads(route.calls[0].request.content) == {"url": "https://acme.example/hook"} + + +@respx.mock +def test_the_secret_bearing_result_keeps_its_secret_out_of_its_repr() -> None: + # A traceback or a debug print of this object must not spill the signing key. + respx.post(COLLECTION).mock( + return_value=httpx.Response(201, json=webhook_endpoint_with_secret()) + ) + with make_client() as client: + created = client.webhook_endpoints.create(url="https://acme.example/hook") + assert "whsec_shown_once" not in repr(created) + assert created.secret == "whsec_shown_once" + + +@respx.mock +def test_the_secret_bearing_result_can_be_downgraded_to_a_loggable_summary() -> None: + respx.post(COLLECTION).mock( + return_value=httpx.Response(201, json=webhook_endpoint_with_secret()) + ) + with make_client() as client: + created = client.webhook_endpoints.create(url="https://acme.example/hook") + summary = created.endpoint + assert isinstance(summary, WebhookEndpoint) + assert (summary.id, summary.url) == (created.id, created.url) + assert not hasattr(summary, "secret") + + +@respx.mock +def test_create_surfaces_the_plan_cap_as_a_quota_error() -> None: + from dodomain import QuotaExceededError + + respx.post(COLLECTION).mock(return_value=httpx.Response(402, json={"error": "quota_exceeded"})) + with pytest.raises(QuotaExceededError), make_client() as client: + client.webhook_endpoints.create(url="https://acme.example/hook") + + +@respx.mock +def test_create_surfaces_a_duplicate_url_as_an_invalid_request() -> None: + respx.post(COLLECTION).mock( + return_value=httpx.Response( + 400, + json={"error": "invalid_request", "message": "this app already sends to that URL"}, + ) + ) + with pytest.raises(InvalidRequestError) as excinfo, make_client() as client: + client.webhook_endpoints.create(url="https://acme.example/hook") + assert excinfo.value.status_code == 400 + + +@respx.mock +def test_update_repoints_the_endpoint_with_a_patch_and_returns_no_secret() -> None: + import json + + route = respx.patch(api("/api/v1/webhook-endpoints/whe_1")).mock( + return_value=httpx.Response(200, json=webhook_endpoint(url="https://acme.example/v2")) + ) + with make_client() as client: + updated = client.webhook_endpoints.update("whe_1", url="https://acme.example/v2") + assert route.calls[0].request.method == "PATCH" + assert json.loads(route.calls[0].request.content) == {"url": "https://acme.example/v2"} + assert updated.url == "https://acme.example/v2" + assert not hasattr(updated, "secret") + + +@respx.mock +def test_delete_returns_what_it_removed_rather_than_an_empty_204() -> None: + route = respx.delete(api("/api/v1/webhook-endpoints/whe_1")).mock( + return_value=httpx.Response(200, json={"id": "whe_1", "deleted": True}) + ) + with make_client() as client: + result = client.webhook_endpoints.delete("whe_1") + assert route.calls[0].request.method == "DELETE" + assert (result.id, result.deleted) == ("whe_1", True) + + +@respx.mock +def test_rotate_secret_posts_to_the_verb_subpath_and_returns_the_new_secret() -> None: + route = respx.post(api("/api/v1/webhook-endpoints/whe_1/rotate-secret")).mock( + return_value=httpx.Response(200, json=webhook_endpoint_with_secret(secret="whsec_v2")) + ) + with make_client() as client: + rotated = client.webhook_endpoints.rotate_secret("whe_1") + assert route.calls[0].request.method == "POST" + assert rotated.secret == "whsec_v2" + assert rotated.id == "whe_1" + + +@respx.mock +def test_an_oauth_token_is_refused_with_a_readable_secret_key_required_signal() -> None: + # 403 rather than 401: the token is valid, it just has no authority here, and + # no scope exists that would grant it. + respx.get(COLLECTION).mock( + return_value=httpx.Response( + 403, + json={ + "error": "forbidden", + "message": "this endpoint requires the app's dd_sk_ secret key", + "details": {"code": "SECRET_KEY_REQUIRED"}, + }, + ) + ) + with pytest.raises(PermissionError_) as excinfo, make_client() as client: + client.webhook_endpoints.list() + assert excinfo.value.secret_key_required is True + assert excinfo.value.required_scope is None + + +@respx.mock +def test_a_scope_missing_403_is_not_mistaken_for_a_secret_key_requirement() -> None: + respx.get(COLLECTION).mock( + return_value=httpx.Response( + 403, + json={ + "error": "forbidden", + "details": {"code": "SCOPE_MISSING", "requiredScope": "connections:read"}, + }, + ) + ) + with pytest.raises(PermissionError_) as excinfo, make_client() as client: + client.webhook_endpoints.list() + assert excinfo.value.secret_key_required is False + assert excinfo.value.required_scope == "connections:read" + + +@respx.mock +def test_another_apps_endpoint_id_is_a_404_not_a_403() -> None: + respx.delete(api("/api/v1/webhook-endpoints/whe_someone_else")).mock( + return_value=httpx.Response(404, json={"error": "not_found"}) + ) + with pytest.raises(NotFoundError), make_client() as client: + client.webhook_endpoints.delete("whe_someone_else") + + +@respx.mock +def test_an_endpoint_id_is_url_encoded_into_the_path() -> None: + route = respx.delete(api("/api/v1/webhook-endpoints/whe%2F1")).mock( + return_value=httpx.Response(200, json={"id": "whe/1", "deleted": True}) + ) + with make_client() as client: + client.webhook_endpoints.delete("whe/1") + assert route.call_count == 1 + + +@pytest.mark.parametrize("bad_id", ["", " "]) +def test_an_empty_endpoint_id_is_refused_locally(bad_id: str) -> None: + with pytest.raises(InvalidRequestError), make_client() as client: + client.webhook_endpoints.delete(bad_id) + + +@respx.mock +def test_an_empty_url_is_refused_before_any_request() -> None: + route = respx.post(COLLECTION).mock(return_value=httpx.Response(201, json={})) + with pytest.raises(InvalidRequestError), make_client() as client: + client.webhook_endpoints.create(url=" ") + assert route.call_count == 0 + + +# ── get(): a client-side lookup, because the API has no read-one route ─────── + + +@respx.mock +def test_get_finds_the_endpoint_by_listing_because_there_is_no_read_one_route() -> None: + route = respx.get(COLLECTION).mock( + return_value=httpx.Response( + 200, json={"endpoints": [webhook_endpoint(id="whe_0"), webhook_endpoint(id="whe_1")]} + ) + ) + with make_client() as client: + found = client.webhook_endpoints.get("whe_1") + assert found.id == "whe_1" + # One LIST request — the SDK never invents a GET /webhook-endpoints/{id}. + assert route.call_count == 1 + assert route.calls[0].request.url.path == "/api/v1/webhook-endpoints" + + +@respx.mock +def test_get_raises_a_not_found_the_sdk_itself_authored_for_an_unknown_id() -> None: + respx.get(COLLECTION).mock( + return_value=httpx.Response(200, json={"endpoints": [webhook_endpoint(id="whe_0")]}) + ) + with pytest.raises(NotFoundError) as excinfo, make_client() as client: + client.webhook_endpoints.get("whe_missing") + # status_code 0 says it plainly: no 404 ever came back from the server. + assert excinfo.value.status_code == 0 + assert "whe_missing" in str(excinfo.value) + + +def test_get_refuses_an_empty_id_locally() -> None: + with pytest.raises(InvalidRequestError), make_client() as client: + client.webhook_endpoints.get(" ")