From 5267fd296be36f4fc97020b1072f437044580ec4 Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:28:36 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20parity=20with=20@dodomain/node=200.6.0?= =?UTF-8?q?=20=E2=80=94=20TLS-issuance=20advisories=20and=20the=20ConnectS?= =?UTF-8?q?essionSummary=20rename=20(0.4.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 62 +++ README.md | 52 ++- src/dodomain/__init__.py | 10 +- src/dodomain/models.py | 144 ++++++- src/dodomain/resources/sessions.py | 16 +- tests/fixtures/openapi_v1_shapes.json | 561 ++++++++++++++++++++++++++ tests/helpers.py | 51 +++ tests/test_models.py | 21 +- tests/test_openapi_contract.py | 192 +++++++++ tests/test_readme_examples.py | 18 + tests/test_tls_issuance_advisories.py | 126 ++++++ tests/test_version.py | 2 +- 12 files changed, 1241 insertions(+), 14 deletions(-) create mode 100644 tests/fixtures/openapi_v1_shapes.json create mode 100644 tests/test_openapi_contract.py create mode 100644 tests/test_tls_issuance_advisories.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b721e29..d4d6deb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,68 @@ Notable changes to `dodomain-sdk`. The import package is `dodomain`. +## 0.4.0 + +Parity with `@dodomain/node` 0.5.0 and 0.6.0, and with the `/v1` contract changes +those tracked. Additive apart from one rename that keeps a working alias, so +upgrading from 0.3.0 is a drop-in. + +### Added + +* **`TlsIssuanceAdvisory`** — why a certificate issuance for a *verified* name may + still fail. Every verify pass now reads the domain's own nameservers for a CAA + policy and a stale `_acme-challenge` record and reports what it found: + `caa_excludes_issuer`, `caa_restricts_issuance`, `stale_acme_challenge`, or + `tls_issuance_unchecked` when the check itself could not complete (unknown is + not the same answer as clean). Carries a `severity`, the `fqdn` it is about, the + `evidence_fqdn` it was read from — routinely a *parent*, since CAA is inherited + — the published `evidence` verbatim, and one human-readable `note`. + + **An advisory never changes the verdict.** `verified` and `present` are computed + without it, so ignoring the field leaves you with exactly the 0.3.0 contract. +* **`VerifyResult.advisories`** — the advisories for this session's + TLS-terminating records, read on the same pass as the verify. +* **`IntegratorSession.tls_issuance_advisories`** — the same shape on + `sessions.get`, as a snapshot of what the LAST verify pass computed. Empty until + a verify has run; not a live read. +* **`VerifyRecord.authoritative_found` / `.public_found`** — what the domain's own + nameservers answered, and what a public recursive resolver sees. The first is + the set `present` is decided from, and the answer to "what did they put there + instead"; the second never gates anything, and trailing the first is the + ordinary, healthy meaning of `outcome == "propagating"`. +* **`App.tls_issuer_ca`** — the CA issuer-domain your end-user certificates are + issued with, or `None` until it is configured in the dashboard. It is what turns + a CAA policy into the actionable `caa_excludes_issuer` rather than the vaguer + `caa_restricts_issuance`. +* `connection.verified` and `session.completed` webhook payloads now carry + `tlsIssuanceAdvisories` **when there is at least one** — absent, not empty, when + there is nothing to say. There is still deliberately no typed event parser (see + `dodomain.webhooks`), so this is a documentation change on the SDK side. +* **`tests/fixtures/openapi_v1_shapes.json` + `tests/test_openapi_contract.py`** — + a mechanical parity guard, in the spirit of `webhook_vectors.json`. The fixture + is a verbatim extract of the published OpenAPI component schemas; the test fails + if a *required* wire field of `POST /v1/sessions`, `GET /v1/sessions/{token}` + (both arms), `POST /v1/sessions/{token}/verify` or `GET /v1/apps` has no field on + the matching model, and again if a mapped field does not survive a round trip. + +### Changed + +* **`Session` is now `ConnectSessionSummary`.** It is the summary of ONE connect + session, and "session" already meant two other things in the platform (the + dashboard login session, and the server-side `ConnectSession` row). + `@dodomain/node` 0.5.0 made the identical rename; this SDK follows so the two + keep answering to the same vocabulary. **`Session` still works** — it is an + alias bound to the same class object, so `isinstance`, equality and existing + imports are unaffected. It is deprecated and will be REMOVED in the next major; + switch your imports now. + +### Notes + +* All new response fields parse tolerantly: a body recorded before the field + existed still reads (as `()` / `None`), while a field that is *present* with the + wrong type still fails loudly. Same rule as `records` / `recordFqdns` / + `previousKeyExpiresAt` before them. + ## 0.3.0 Parity with the rotation-overlap contract the API shipped on 2026-08-20 (and diff --git a/README.md b/README.md index f816e0b..5df8c9f 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,12 @@ 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. +> The type `sessions.create` returns is `ConnectSessionSummary`. It was called +> `Session` through 0.3.0, and `from dodomain import Session` still works — the +> alias is the same class object, is deprecated, and goes away in the next major. +> `@dodomain/node` 0.5.0 made the identical rename, for the identical reason: +> "session" already meant two other things in the platform. + ### Reading a session back Two different reads, and the difference matters. **From your server, use @@ -89,6 +95,7 @@ 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 +state.tls_issuance_advisories # what the last verify pass found about certificates ``` The **token-public** routes are the other read: they take the session token in the @@ -102,6 +109,8 @@ result = client.sessions.verify(session.token) # check live DNS now for record in result.records: print(record.fqdn, record.type, record.outcome) + print(record.authoritative_found) # what their OWN nameservers answered + print(record.public_found) # what a public resolver sees — trails, when propagating ``` `retrieve` raises `ExpiredError` forever once the TTL passes — correct for a @@ -116,6 +125,42 @@ session.cloudflare_start_url # tier-1 Cloudflare OAuth flow session.domain_connect_start_url # tier-2 Domain Connect one-click ``` +### Will the certificate issue? + +A verified record means DNS points at you. It does not mean a CA will hand you a +certificate for that name. Every verify pass reads the domain's own nameservers +for the two things that usually stop issuance and reports them as **advisories**: + +```python +result = client.sessions.verify(session.token) + +for advisory in result.advisories: + print(advisory.severity, advisory.code, advisory.fqdn) + print(advisory.note) # one human-readable sentence + print(advisory.evidence_fqdn, advisory.evidence) # where we read it, and what it said +``` + +| `code` | What it means | +| ------------------------- | -------------------------------------------------------------------- | +| `caa_excludes_issuer` | A CAA policy leaves out the CA your app is configured with | +| `caa_restricts_issuance` | A CAA policy exists and no CA is configured to judge it against | +| `stale_acme_challenge` | `_acme-challenge.` already holds a TXT or CNAME | +| `tls_issuance_unchecked` | The check itself could not complete — unknown, which is not clean | + +**An advisory never changes the verdict.** `verified` and `present` are computed +without it, so a consumer that ignores the field sees exactly the contract that +shipped before advisories existed. `severity` is `"warning"` when it plausibly +breaks issuance for you and `"info"` when it is a fact we could not turn into a +verdict. An empty list means we looked and found nothing; a check that could not +run is an entry, not silence. + +The same shape rides on `sessions.get(...).tls_issuance_advisories` (a snapshot +from the last verify pass, empty until one has run) and, when non-empty, on the +`connection.verified` and `session.completed` webhook payloads as +`tlsIssuanceAdvisories`. Configure which CA you issue with in the dashboard — it +is what turns the vague `caa_restricts_issuance` into the actionable +`caa_excludes_issuer`, and it reads back as `app.tls_issuer_ca`. + ### Async `AsyncDoDomain` has the identical resource tree, arguments and return types — @@ -293,6 +338,7 @@ check.guide.steps # copy-ready manual instructions ```python for app in client.apps.list(): print(app.id, app.name, app.public_key, app.sandbox) + print(app.tls_issuer_ca) # the CA your certificates are issued with, or None ``` A secret key sees exactly its own app — listing siblings would widen a single @@ -358,7 +404,11 @@ 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. +`connections.get` / `reverify` / `disconnect` are keyed by. `connection.verified` +and `session.completed` additionally carry `tlsIssuanceAdvisories` **when there is +at least one**, in the shape described under +[Will the certificate issue?](#will-the-certificate-issue) — absent, not empty, +when there is nothing to say. Event types: `connection.verified`, `connection.failed`, `connection.disconnected`, `session.completed`, `session.abandoned`. A receiver diff --git a/src/dodomain/__init__.py b/src/dodomain/__init__.py index 013f11a..0ccf29b 100644 --- a/src/dodomain/__init__.py +++ b/src/dodomain/__init__.py @@ -20,7 +20,7 @@ from __future__ import annotations -__version__ = "0.3.0" +__version__ = "0.4.0" from ._client import AsyncDoDomain, DoDomain from ._transport import DEFAULT_BASE_URL, RateLimitSnapshot @@ -49,6 +49,7 @@ Connection, ConnectionPage, ConnectionStatus, + ConnectSessionSummary, DeletedWebhookEndpoint, DetectResult, DisconnectResult, @@ -66,6 +67,9 @@ Session, SessionWarning, Tier, + TlsIssuanceAdvisory, + TlsIssuanceAdvisoryCode, + TlsIssuanceAdvisorySeverity, VerifyOutcome, VerifyRecord, VerifyResult, @@ -88,6 +92,7 @@ "Connection", "ConnectionPage", "ConnectionStatus", + "ConnectSessionSummary", "DeletedWebhookEndpoint", "DetectResult", "DisconnectResult", @@ -120,6 +125,9 @@ "Session", "SessionWarning", "Tier", + "TlsIssuanceAdvisory", + "TlsIssuanceAdvisoryCode", + "TlsIssuanceAdvisorySeverity", "VerifyOutcome", "VerifyRecord", "VerifyResult", diff --git a/src/dodomain/models.py b/src/dodomain/models.py index a845981..7dfb6ae 100644 --- a/src/dodomain/models.py +++ b/src/dodomain/models.py @@ -32,6 +32,7 @@ "Connection", "ConnectionPage", "ConnectionStatus", + "ConnectSessionSummary", "DeletedWebhookEndpoint", "DetectResult", "DisconnectResult", @@ -49,6 +50,9 @@ "Session", "SessionWarning", "Tier", + "TlsIssuanceAdvisory", + "TlsIssuanceAdvisoryCode", + "TlsIssuanceAdvisorySeverity", "VerifyOutcome", "VerifyRecord", "VerifyResult", @@ -65,6 +69,19 @@ ApexToken = Literal["@", "(blank)", "%domain%"] WarningCode = Literal["duplicate_host_label"] +#: Why a certificate issuance for a verified name may still fail. Transcribed +#: from ``TLS_ISSUANCE_ADVISORY_CODES`` in the app repo +#: (``packages/core/src/tls-issuance-advisories.ts``). +TlsIssuanceAdvisoryCode = Literal[ + "caa_excludes_issuer", + "caa_restricts_issuance", + "stale_acme_challenge", + "tls_issuance_unchecked", +] + +#: ``warning`` plausibly breaks issuance; ``info`` is a fact without a verdict. +TlsIssuanceAdvisorySeverity = Literal["warning", "info"] + #: How long the *previous* secret key keeps authenticating after a rotation. #: ``0`` — the default — is an immediate cutover; ``1`` and ``24`` are the only #: windows the API offers. Transcribed from ``zRotateAppSecretKeyInput`` in @@ -75,6 +92,16 @@ #: record-type home (``packages/core/src/record-capabilities.ts``). RECORD_TYPES: tuple[str, ...] = ("A", "AAAA", "CNAME", "TXT", "MX") +#: The advisory codes and severities, for the runtime check the ``Literal``s +#: above only make at type-check time. +TLS_ISSUANCE_ADVISORY_CODES: tuple[str, ...] = ( + "caa_excludes_issuer", + "caa_restricts_issuance", + "stale_acme_challenge", + "tls_issuance_unchecked", +) +TLS_ISSUANCE_ADVISORY_SEVERITIES: tuple[str, ...] = ("warning", "info") + #: The overlap windows ``keys.rotate`` accepts, for the runtime check the #: ``Literal`` above only makes at type-check time. OVERLAP_HOURS_VALUES: tuple[int, ...] = (0, 1, 24) @@ -304,9 +331,72 @@ def _from_api(cls, payload: Any) -> SessionWarning: @dataclass(frozen=True, slots=True) -class Session: +class TlsIssuanceAdvisory: + """Why a certificate issuance for a verified name may still fail. + + Read from the domain's own nameservers on every verify pass (CAA policy plus + a stale ``_acme-challenge`` record) and carried on four surfaces: the + :class:`VerifyResult`, the :class:`IntegratorSession` read, and the + ``connection.verified`` / ``session.completed`` webhook payloads. + + **It is advice about YOUR next step — issuing the certificate — and never + part of the verify verdict.** ``verified`` and ``present`` are computed + without it, so a consumer that ignores this field sees exactly the + pre-advisory contract. + + An empty list means "we looked and found nothing". A check that could not + complete is itself an entry (``tls_issuance_unchecked``), never silence — + unknown is not the same answer as clean. + + Attributes: + code: ``caa_excludes_issuer`` — a CAA policy leaves out the CA configured + on the app (:attr:`App.tls_issuer_ca`). + ``caa_restricts_issuance`` — a CAA policy exists and no CA is + configured to judge it against. + ``stale_acme_challenge`` — ``_acme-challenge.`` already holds a + TXT or CNAME. + ``tls_issuance_unchecked`` — the check itself could not complete. + severity: ``warning`` plausibly breaks issuance for you; ``info`` is a + fact we could not turn into a verdict. + fqdn: The name the certificate is for. + evidence_fqdn: Where the evidence was read — the CAA owner name (which may + be a parent of ``fqdn``), or ``_acme-challenge.``. + evidence: The published values behind the verdict, verbatim. + note: One human-readable sentence. + """ + + code: TlsIssuanceAdvisoryCode + severity: TlsIssuanceAdvisorySeverity + fqdn: str + evidence_fqdn: str + evidence: tuple[str, ...] + note: str + raw: dict[str, Any] | None = field(default=None, compare=False, repr=False) + + @classmethod + def _from_api(cls, payload: Any) -> TlsIssuanceAdvisory: + data = _obj(payload, "tls issuance advisory") + return cls( + code=_literal(data, "code", TLS_ISSUANCE_ADVISORY_CODES), + severity=_literal(data, "severity", TLS_ISSUANCE_ADVISORY_SEVERITIES), + fqdn=_req_str(data, "fqdn"), + evidence_fqdn=_req_str(data, "evidenceFqdn"), + evidence=_str_list(data, "evidence"), + note=_req_str(data, "note"), + raw=data, + ) + + +@dataclass(frozen=True, slots=True) +class ConnectSessionSummary: """A freshly minted connect session — the response of ``sessions.create``. + Named for what it is: the summary of ONE connect session. The old name, + ``Session``, was the third meaning of "session" in the platform (there is + also the dashboard login session and the server-side ``ConnectSession`` row), + so ``@dodomain/node`` 0.5.0 renamed its twin to ``ConnectSessionSummary`` and + this SDK follows. :data:`Session` remains as a deprecated alias. + Attributes: id: Stable session id; it is also the ``sessionId`` on every webhook. token: The capability for the token-public routes and the hosted flow. @@ -352,7 +442,7 @@ def domain_connect_start_url(self) -> str: return f"{self.base_url}/api/v1/sessions/{quote(self.token, safe='')}/domain-connect/start" @classmethod - def _from_api(cls, payload: Any, *, base_url: str) -> Session: + def _from_api(cls, payload: Any, *, base_url: str) -> ConnectSessionSummary: data = _obj(payload, "session") return cls( id=_req_str(data, "id"), @@ -366,6 +456,15 @@ def _from_api(cls, payload: Any, *, base_url: str) -> Session: ) +#: Deprecated alias for :class:`ConnectSessionSummary`, kept so existing +#: ``from dodomain import Session`` imports and ``isinstance`` checks keep +#: working. It is the SAME class object, not a subclass or a copy, so equality +#: and ``repr`` are unchanged. ``@dodomain/node`` 0.5.0 made the same move and +#: marked its alias for removal in the next major; this one goes at the same +#: time. Switch your imports now. +Session = ConnectSessionSummary + + @dataclass(frozen=True, slots=True) class PublicSession: """A session read back through the token-public ``GET /v1/sessions/{token}``. @@ -447,6 +546,11 @@ class IntegratorSession: #: 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 + #: The TLS-issuance advisories the LAST verify pass computed — a snapshot of + #: DNS at that moment, not a live read, and empty until a verify has run. + #: Additive on the wire, so it carries a default and sits after the original + #: fields; see :class:`TlsIssuanceAdvisory`. + tls_issuance_advisories: tuple[TlsIssuanceAdvisory, ...] = () raw: dict[str, Any] | None = field(default=None, compare=False, repr=False) @classmethod @@ -465,6 +569,10 @@ def _from_api(cls, payload: Any) -> IntegratorSession: created_at=_req_datetime(data, "createdAt"), expires_at=_req_datetime(data, "expiresAt"), expired=_req_bool(data, "expired"), + tls_issuance_advisories=tuple( + TlsIssuanceAdvisory._from_api(item) + for item in _opt_list(data, "tlsIssuanceAdvisories") + ), raw=data, ) @@ -631,6 +739,14 @@ class VerifyRecord: note: str outcome: VerifyOutcome authoritative_error: str | None = None + #: What the domain's OWN nameservers answered for this name. This is the set + #: ``present`` is decided from; when it disagrees with what you asked for, it + #: is the answer to "what did they put there instead". Additive on the wire. + authoritative_found: tuple[str, ...] = () + #: The same answers from a public recursive resolver. Informational only — it + #: never gates ``present``, and trailing :attr:`authoritative_found` is the + #: ordinary, healthy meaning of ``outcome == "propagating"``. Additive. + public_found: tuple[str, ...] = () raw: dict[str, Any] | None = field(default=None, compare=False, repr=False) @classmethod @@ -647,16 +763,28 @@ def _from_api(cls, payload: Any) -> VerifyRecord: ("verified", "propagating", "absent", "indeterminate", "domain_not_found"), ), authoritative_error=_opt_str(data, "authoritativeError"), + authoritative_found=tuple(str(item) for item in _opt_list(data, "authoritativeFound")), + public_found=tuple(str(item) for item in _opt_list(data, "publicFound")), raw=data, ) @dataclass(frozen=True, slots=True) class VerifyResult: - """The result of checking a session's records against live DNS.""" + """The result of checking a session's records against live DNS. + + :attr:`advisories` is about the certificate you will issue NEXT, not about + whether the records are there — it never feeds :attr:`verified`. Ignoring it + leaves you with exactly the pre-advisory contract. + """ verified: bool records: tuple[VerifyRecord, ...] + #: TLS-issuance advisories for this session's TLS-terminating records + #: (A/AAAA/CNAME), read on the same pass. Empty means "we looked and found + #: nothing"; a check that could not complete is an entry, not silence. + #: Additive on the wire, so it carries a default. + advisories: tuple[TlsIssuanceAdvisory, ...] = () raw: dict[str, Any] | None = field(default=None, compare=False, repr=False) @classmethod @@ -665,6 +793,9 @@ def _from_api(cls, payload: Any) -> VerifyResult: return cls( verified=_req_bool(data, "verified"), records=tuple(VerifyRecord._from_api(item) for item in _req_list(data, "records")), + advisories=tuple( + TlsIssuanceAdvisory._from_api(item) for item in _opt_list(data, "advisories") + ), raw=data, ) @@ -762,6 +893,12 @@ class App: logo_url: str | None brand_color: str | None created_at: datetime + #: The CAA issuer-domain your end-user certificates are issued with (e.g. + #: ``"letsencrypt.org"``); ``None`` until it is configured in the dashboard. + #: It is what turns a CAA policy into a ``caa_excludes_issuer`` advisory + #: rather than the weaker ``caa_restricts_issuance``. Additive on the wire, + #: so it carries a default and sits after the original fields. + tls_issuer_ca: str | None = None raw: dict[str, Any] | None = field(default=None, compare=False, repr=False) @classmethod @@ -775,6 +912,7 @@ def _from_api(cls, payload: Any) -> App: logo_url=_opt_str(data, "logoUrl"), brand_color=_opt_str(data, "brandColor"), created_at=_req_datetime(data, "createdAt"), + tls_issuer_ca=_opt_str(data, "tlsIssuerCa"), raw=data, ) diff --git a/src/dodomain/resources/sessions.py b/src/dodomain/resources/sessions.py index 5ecb46d..f9512f5 100644 --- a/src/dodomain/resources/sessions.py +++ b/src/dodomain/resources/sessions.py @@ -22,11 +22,11 @@ from dodomain._validation import validate_create_session from dodomain.errors import InvalidRequestError from dodomain.models import ( + ConnectSessionSummary, DetectResult, DnsRecord, IntegratorSession, PublicSession, - Session, VerifyResult, ) @@ -108,7 +108,7 @@ def create( recipe: str | None = None, return_url: str | None = None, idempotency_key: str | None = None, - ) -> Session: + ) -> ConnectSessionSummary: """Mint a connect session and get the URL to send the customer to. Args: @@ -128,7 +128,7 @@ def create( call mints a new session, token and quota unit. Returns: - The new :class:`~dodomain.models.Session`. + The new :class:`~dodomain.models.ConnectSessionSummary`. Raises: InvalidRequestError: Locally, before any HTTP request, when the input @@ -144,7 +144,9 @@ def create( is_oauth=self._client.is_oauth, idempotency_key=idempotency_key, ) - return Session._from_api(self._client.request(spec), base_url=self._client.base_url) + return ConnectSessionSummary._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. @@ -228,7 +230,7 @@ async def create( recipe: str | None = None, return_url: str | None = None, idempotency_key: str | None = None, - ) -> Session: + ) -> ConnectSessionSummary: """Mint a connect session. See :meth:`Sessions.create`.""" spec = _spec_create( domain=domain, @@ -239,7 +241,9 @@ async def create( is_oauth=self._client.is_oauth, idempotency_key=idempotency_key, ) - return Session._from_api(await self._client.request(spec), base_url=self._client.base_url) + return ConnectSessionSummary._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`.""" diff --git a/tests/fixtures/openapi_v1_shapes.json b/tests/fixtures/openapi_v1_shapes.json new file mode 100644 index 0000000..77cafbe --- /dev/null +++ b/tests/fixtures/openapi_v1_shapes.json @@ -0,0 +1,561 @@ +{ + "_comment": "GENERATED — do not hand-edit. Verbatim extract of the named component schemas from the DoDomain monorepo's committed apps/docs/public/openapi.json, which is itself generated from packages/core/src/schemas.ts. Regenerate with the recipe in tests/test_openapi_contract.py when the API contract moves. tests/test_openapi_contract.py fails if a required wire field here has no field on the matching model.", + "apiVersion": "v1", + "schemas": { + "CreateSessionResponse": { + "properties": { + "connectUrl": { + "minLength": 1, + "type": "string" + }, + "expiresAt": { + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "type": "string" + }, + "id": { + "type": "string" + }, + "records": { + "items": { + "properties": { + "fqdn": { + "minLength": 1, + "type": "string" + }, + "host": { + "minLength": 1, + "type": "string" + }, + "type": { + "enum": [ + "A", + "AAAA", + "CNAME", + "TXT", + "MX" + ], + "type": "string" + } + }, + "required": [ + "type", + "host", + "fqdn" + ], + "type": "object" + }, + "type": "array" + }, + "token": { + "minLength": 1, + "type": "string" + }, + "warnings": { + "items": { + "properties": { + "code": { + "enum": [ + "duplicate_host_label" + ], + "type": "string" + }, + "fqdn": { + "minLength": 1, + "type": "string" + }, + "host": { + "type": "string" + }, + "message": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "code", + "message", + "host", + "fqdn" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "id", + "token", + "expiresAt", + "connectUrl", + "records" + ], + "type": "object" + }, + "PublicSession": { + "properties": { + "detectedProvider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "domain": { + "type": "string" + }, + "expiresAt": { + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "type": "string" + }, + "id": { + "type": "string" + }, + "recipe": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "records": { + "items": { + "properties": { + "host": { + "minLength": 1, + "type": "string" + }, + "priority": { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + "ttl": { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + "type": { + "enum": [ + "A", + "AAAA", + "CNAME", + "TXT", + "MX" + ], + "type": "string" + }, + "value": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "type", + "host", + "value" + ], + "type": "object" + }, + "type": "array" + }, + "returnUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "status": { + "type": "string" + }, + "tier": { + "anyOf": [ + { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "domain", + "records", + "recipe", + "status", + "tier", + "detectedProvider", + "returnUrl", + "expiresAt" + ], + "type": "object" + }, + "IntegratorSession": { + "properties": { + "appId": { + "type": "string" + }, + "connectionId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "type": "string" + }, + "detectedProvider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "domain": { + "type": "string" + }, + "expired": { + "type": "boolean" + }, + "expiresAt": { + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "type": "string" + }, + "id": { + "type": "string" + }, + "recipe": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "records": { + "items": { + "properties": { + "fqdn": { + "minLength": 1, + "type": "string" + }, + "host": { + "minLength": 1, + "type": "string" + }, + "type": { + "enum": [ + "A", + "AAAA", + "CNAME", + "TXT", + "MX" + ], + "type": "string" + } + }, + "required": [ + "type", + "host", + "fqdn" + ], + "type": "object" + }, + "type": "array" + }, + "status": { + "type": "string" + }, + "tier": { + "anyOf": [ + { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "tlsIssuanceAdvisories": { + "items": { + "properties": { + "code": { + "enum": [ + "caa_excludes_issuer", + "caa_restricts_issuance", + "stale_acme_challenge", + "tls_issuance_unchecked" + ], + "type": "string" + }, + "evidence": { + "items": { + "type": "string" + }, + "type": "array" + }, + "evidenceFqdn": { + "type": "string" + }, + "fqdn": { + "type": "string" + }, + "note": { + "type": "string" + }, + "severity": { + "enum": [ + "warning", + "info" + ], + "type": "string" + } + }, + "required": [ + "code", + "severity", + "fqdn", + "evidenceFqdn", + "evidence", + "note" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "id", + "appId", + "domain", + "records", + "recipe", + "status", + "tier", + "detectedProvider", + "connectionId", + "createdAt", + "expiresAt", + "expired", + "tlsIssuanceAdvisories" + ], + "type": "object" + }, + "VerifySessionResponse": { + "properties": { + "advisories": { + "items": { + "properties": { + "code": { + "enum": [ + "caa_excludes_issuer", + "caa_restricts_issuance", + "stale_acme_challenge", + "tls_issuance_unchecked" + ], + "type": "string" + }, + "evidence": { + "items": { + "type": "string" + }, + "type": "array" + }, + "evidenceFqdn": { + "type": "string" + }, + "fqdn": { + "type": "string" + }, + "note": { + "type": "string" + }, + "severity": { + "enum": [ + "warning", + "info" + ], + "type": "string" + } + }, + "required": [ + "code", + "severity", + "fqdn", + "evidenceFqdn", + "evidence", + "note" + ], + "type": "object" + }, + "type": "array" + }, + "records": { + "items": { + "properties": { + "authoritativeError": { + "type": "string" + }, + "authoritativeFound": { + "items": { + "type": "string" + }, + "type": "array" + }, + "fqdn": { + "type": "string" + }, + "note": { + "type": "string" + }, + "outcome": { + "enum": [ + "verified", + "propagating", + "absent", + "indeterminate", + "domain_not_found" + ], + "type": "string" + }, + "present": { + "type": "boolean" + }, + "publicFound": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "type": "string" + } + }, + "required": [ + "fqdn", + "type", + "present", + "note", + "outcome", + "authoritativeFound", + "publicFound" + ], + "type": "object" + }, + "type": "array" + }, + "verified": { + "type": "boolean" + } + }, + "required": [ + "verified", + "records", + "advisories" + ], + "type": "object" + }, + "ListAppsResponse": { + "properties": { + "apps": { + "items": { + "properties": { + "brandColor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "type": "string" + }, + "id": { + "type": "string" + }, + "logoUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + }, + "publicKey": { + "type": "string" + }, + "sandbox": { + "type": "boolean" + }, + "tlsIssuerCa": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "name", + "publicKey", + "sandbox", + "logoUrl", + "brandColor", + "tlsIssuerCa", + "createdAt" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "apps" + ], + "type": "object" + } + } +} diff --git a/tests/helpers.py b/tests/helpers.py index 6d154d1..3e21bf2 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -60,6 +60,19 @@ def make_async_client(**kwargs: Any) -> AsyncDoDomain: key: value for key, value in CREATE_SESSION_RESPONSE.items() if key != "records" } +#: One TLS-issuance advisory, exactly as `zTlsIssuanceAdvisory` puts it on the +#: wire. The SAME shape rides on four surfaces (the verify response, the authed +#: session read, and the `connection.verified` / `session.completed` webhook +#: payloads), which is why it is one constant here rather than four literals. +TLS_ISSUANCE_ADVISORY: dict[str, Any] = { + "code": "caa_excludes_issuer", + "severity": "warning", + "fqdn": "app.customer.com", + "evidenceFqdn": "customer.com", + "evidence": ['issue "digicert.com"'], + "note": "The CAA policy on customer.com does not allow letsencrypt.org to issue.", +} + #: 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. @@ -76,6 +89,15 @@ def make_async_client(**kwargs: Any) -> AsyncDoDomain: "createdAt": "2026-08-05T12:00:00.000Z", "expiresAt": "2026-08-06T12:00:00.000Z", "expired": False, + "tlsIssuanceAdvisories": [TLS_ISSUANCE_ADVISORY], +} + +#: The authed read exactly as the API shipped it before advisories existed — the +#: shape a cached or archived payload still has. +LEGACY_INTEGRATOR_SESSION_RESPONSE: dict[str, Any] = { + key: value + for key, value in INTEGRATOR_SESSION_RESPONSE.items() + if key != "tlsIssuanceAdvisories" } PUBLIC_SESSION_RESPONSE: dict[str, Any] = { @@ -142,6 +164,8 @@ def make_async_client(**kwargs: Any) -> AsyncDoDomain: "present": True, "note": "matches", "outcome": "verified", + "authoritativeFound": ["cname.dodomain.io"], + "publicFound": ["cname.dodomain.io"], }, { "fqdn": "customer.com", @@ -150,8 +174,27 @@ def make_async_client(**kwargs: Any) -> AsyncDoDomain: "note": "not visible yet", "outcome": "propagating", "authoritativeError": "NS_RESOLUTION_FAILED", + # The healthy meaning of `propagating`: the domain's own nameservers + # already answer, the public resolver has not caught up. + "authoritativeFound": ["dodomain-verify=abc"], + "publicFound": [], }, ], + "advisories": [TLS_ISSUANCE_ADVISORY], +} + +#: A verify response exactly as the API shipped it before `advisories` and the +#: found-sets existed — the shape a cached or archived payload still has. +LEGACY_VERIFY_RESPONSE: dict[str, Any] = { + "verified": VERIFY_RESPONSE["verified"], + "records": [ + { + key: value + for key, value in record.items() + if key not in ("authoritativeFound", "publicFound") + } + for record in VERIFY_RESPONSE["records"] + ], } @@ -225,11 +268,19 @@ def webhook_endpoint_with_secret(**overrides: Any) -> dict[str, Any]: "sandbox": False, "logoUrl": None, "brandColor": "#0E6B4E", + "tlsIssuerCa": "letsencrypt.org", "createdAt": "2026-07-01T00:00:00.000Z", } ] } +#: The apps list exactly as the API shipped it before `tlsIssuerCa` existed. +LEGACY_LIST_APPS_RESPONSE: dict[str, Any] = { + "apps": [ + {key: value for key, value in LIST_APPS_RESPONSE["apps"][0].items() if key != "tlsIssuerCa"} + ] +} + DISCONNECT_RESPONSE: dict[str, Any] = { "id": "conn_1", "disconnectedAt": "2026-08-05T12:00:00.000Z", diff --git a/tests/test_models.py b/tests/test_models.py index c767858..f1d9cb4 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -5,7 +5,14 @@ import pytest -from dodomain import Connection, DnsRecord, InvalidResponseError, ProviderGuide, Session +from dodomain import ( + Connection, + ConnectSessionSummary, + DnsRecord, + InvalidResponseError, + ProviderGuide, + Session, +) from dodomain.models import parse_datetime from tests.helpers import CREATE_SESSION_RESPONSE, PROVIDER_GUIDE, connection @@ -109,13 +116,23 @@ def test_a_guide_apex_token_outside_the_documented_union_is_rejected() -> None: def test_the_session_url_builders_encode_an_awkward_token() -> None: - session = Session._from_api( + session = ConnectSessionSummary._from_api( {**CREATE_SESSION_RESPONSE, "token": "a b/c"}, base_url="https://app.dodomain.io" ) assert session.cloudflare_start_url.endswith("/sessions/a%20b%2Fc/cloudflare/start") assert session.domain_connect_start_url.endswith("/sessions/a%20b%2Fc/domain-connect/start") +def test_the_deprecated_session_alias_is_the_same_class_not_a_copy() -> None: + # `Session` was the name through 0.3.0. It must stay the SAME class object so + # existing `isinstance` checks, equality and pickles keep working — a subclass + # or a second dataclass would fork the two names silently, which is exactly + # what `@dodomain/node`'s type-level pin prevents on the TypeScript side. + assert Session is ConnectSessionSummary + parsed = Session._from_api(CREATE_SESSION_RESPONSE, base_url="https://app.dodomain.io") + assert isinstance(parsed, ConnectSessionSummary) + + def test_a_boolean_is_not_accepted_where_an_integer_is_required() -> None: with pytest.raises(InvalidResponseError): DnsRecord._from_api({"type": "A", "host": "@", "value": "x", "ttl": True}) diff --git a/tests/test_openapi_contract.py b/tests/test_openapi_contract.py new file mode 100644 index 0000000..9c85956 --- /dev/null +++ b/tests/test_openapi_contract.py @@ -0,0 +1,192 @@ +"""The mechanical guard that this SDK models the API's ACTUAL `/v1` contract. + +`tests/fixtures/openapi_v1_shapes.json` is a verbatim extract of the component +schemas from the monorepo's committed `apps/docs/public/openapi.json`, which is +itself generated from `packages/core/src/schemas.ts` — the same zod schemas the +handlers validate with. So the required-field lists in that fixture are not a +Python-flavoured paraphrase of the contract; they are the contract. + +Two things are asserted per schema: + +1. **Every required wire field is mapped to a model attribute.** The mapping is + spelled out here by hand, and its key set must EQUAL the schema's `required` + list. A field the API makes required and this SDK does not read fails the + suite the moment the fixture is regenerated — which is the whole point, and + the same job `tests/fixtures/webhook_vectors.json` does for the signer. +2. **A payload synthesized from the schema round-trips through the model.** Each + mapped attribute must come back holding the synthesized value, so a mapping + entry cannot be satisfied by a field that silently parses to its default. + +Regenerating the fixture (run from a checkout of the `dodomain` monorepo, whose +`apps/docs/public/openapi.json` is the source of truth):: + + node -e "const fs=require('fs'); + const spec=JSON.parse(fs.readFileSync('apps/docs/public/openapi.json','utf8')); + const want=['CreateSessionResponse','PublicSession','IntegratorSession', + 'VerifySessionResponse','ListAppsResponse']; + const old=JSON.parse(fs.readFileSync(DEST,'utf8')); + fs.writeFileSync(DEST, JSON.stringify({...old, apiVersion: spec.info.version, + schemas: Object.fromEntries(want.map(k=>[k,spec.components.schemas[k]]))}, null, 2)+'\\n');" +""" + +from __future__ import annotations + +import json +from datetime import datetime +from pathlib import Path +from typing import Any + +import pytest + +from dodomain.models import ( + App, + ConnectSessionSummary, + IntegratorSession, + PublicSession, + VerifyResult, +) + +SCHEMAS: dict[str, Any] = json.loads( + (Path(__file__).parent / "fixtures" / "openapi_v1_shapes.json").read_text(encoding="utf-8") +)["schemas"] + +#: Wire key -> the model attribute that must hold it. One entry per REQUIRED +#: field of the schema; the equality assertion below is what makes the list +#: exhaustive rather than aspirational. +REQUIRED_FIELD_MAP: dict[str, dict[str, str]] = { + "CreateSessionResponse": { + "id": "id", + "token": "token", + "expiresAt": "expires_at", + "connectUrl": "connect_url", + "records": "records", + }, + "PublicSession": { + "id": "id", + "domain": "domain", + "records": "records", + "recipe": "recipe", + "status": "status", + "tier": "tier", + "detectedProvider": "detected_provider", + "returnUrl": "return_url", + "expiresAt": "expires_at", + }, + "IntegratorSession": { + "id": "id", + "appId": "app_id", + "domain": "domain", + "records": "records", + "recipe": "recipe", + "status": "status", + "tier": "tier", + "detectedProvider": "detected_provider", + "connectionId": "connection_id", + "createdAt": "created_at", + "expiresAt": "expires_at", + "expired": "expired", + "tlsIssuanceAdvisories": "tls_issuance_advisories", + }, + "VerifySessionResponse": { + "verified": "verified", + "records": "records", + "advisories": "advisories", + }, + # ListAppsResponse's only required field is the envelope's `apps` array; the + # shape that matters is its item, which `App` models. + "ListAppsResponse.apps.items": { + "id": "id", + "name": "name", + "publicKey": "public_key", + "sandbox": "sandbox", + "logoUrl": "logo_url", + "brandColor": "brand_color", + "tlsIssuerCa": "tls_issuer_ca", + "createdAt": "created_at", + }, +} + + +def _schema(name: str) -> dict[str, Any]: + """Resolve a dotted fixture path like ``ListAppsResponse.apps.items``.""" + head, _, rest = name.partition(".") + node: dict[str, Any] = SCHEMAS[head] + for segment in filter(None, rest.split(".")): + node = node["items"] if segment == "items" else node["properties"][segment] + return node + + +def _sample(node: dict[str, Any], key: str) -> Any: + """One value satisfying this schema node, distinctive enough to trace back. + + Nullable fields deliberately get their NON-null branch: a null would parse + into the same `None` an unread field leaves behind, which is exactly the + silent-default failure this suite exists to catch. + """ + if "anyOf" in node: + branches = [b for b in node["anyOf"] if b.get("type") != "null"] + return _sample(branches[0], key) + if "enum" in node: + return node["enum"][0] + node_type = node.get("type") + if node_type == "array": + return [_sample(node["items"], key)] + if node_type == "object": + return {k: _sample(node["properties"][k], k) for k in node.get("required", [])} + if node_type == "boolean": + return True + if node_type in ("integer", "number"): + return 1 + if node.get("format") == "date-time": + return "2026-09-08T12:00:00.000Z" + return f"sample-{key}" + + +def _synthesize(name: str) -> dict[str, Any]: + schema = _schema(name) + return {k: _sample(schema["properties"][k], k) for k in schema["required"]} + + +@pytest.mark.parametrize("name", sorted(REQUIRED_FIELD_MAP)) +def test_every_required_field_of_the_published_contract_has_a_model_field(name: str) -> None: + # An API field this SDK does not read is invisible until an integrator needs + # it. Equality (not a subset check) is what makes a newly-required field a + # failing test the moment the fixture is regenerated. + assert set(_schema(name)["required"]) == set(REQUIRED_FIELD_MAP[name]) + + +PARSERS = { + "CreateSessionResponse": lambda p: ConnectSessionSummary._from_api( + p, base_url="https://app.dodomain.io" + ), + "PublicSession": PublicSession._from_api, + "IntegratorSession": IntegratorSession._from_api, + "VerifySessionResponse": VerifyResult._from_api, + "ListAppsResponse.apps.items": App._from_api, +} + + +@pytest.mark.parametrize("name", sorted(REQUIRED_FIELD_MAP)) +def test_a_body_built_from_the_published_schema_round_trips_into_the_model(name: str) -> None: + payload = _synthesize(name) + model = PARSERS[name](payload) + for wire_key, attribute in REQUIRED_FIELD_MAP[name].items(): + value = getattr(model, attribute) + wire_value = payload[wire_key] + if isinstance(wire_value, list): + # Nested models are parsed, not passed through, so compare arity — + # an empty tuple here means the field was dropped on the floor. + assert len(value) == len(wire_value), f"{name}.{wire_key} lost its items" + elif isinstance(value, datetime): + assert value.isoformat() == "2026-09-08T12:00:00+00:00" + else: + assert value == wire_value, f"{name}.{wire_key} did not reach .{attribute}" + + +def test_the_fixture_is_pinned_to_the_v1_surface() -> None: + # A fixture regenerated against some future /v2 document would silently start + # asserting the wrong contract. + contract = json.loads( + (Path(__file__).parent / "fixtures" / "openapi_v1_shapes.json").read_text(encoding="utf-8") + ) + assert contract["apiVersion"] == "v1" diff --git a/tests/test_readme_examples.py b/tests/test_readme_examples.py index 7b1351c..3bd1e18 100644 --- a/tests/test_readme_examples.py +++ b/tests/test_readme_examples.py @@ -97,10 +97,12 @@ def test_the_token_public_block_runs() -> None: detected = client.sessions.detect(session.token) result = client.sessions.verify(session.token) rows = [(r.fqdn, r.type, r.outcome) for r in result.records] + found = [(r.authoritative_found, r.public_found) for r in result.records] assert public.status == "pending" assert detected.provider == "cloudflare" assert rows[0] == ("app.customer.com", "CNAME", "verified") + assert found[0] == (("cname.dodomain.io",), ("cname.dodomain.io",)) assert session.cloudflare_start_url.endswith("/cloudflare/start") assert session.domain_connect_start_url.endswith("/domain-connect/start") @@ -165,6 +167,21 @@ def test_the_session_read_back_block_runs() -> None: assert state.expired is False assert state.connection_id == "conn_1" assert state.records[0].fqdn == "app.app.customer.com" + assert state.tls_issuance_advisories[0].code == "caa_excludes_issuer" + + +@respx.mock +def test_the_will_the_certificate_issue_block_runs() -> None: + respx.post(api("/api/v1/sessions/tok/verify")).mock( + return_value=httpx.Response(200, json=VERIFY_RESPONSE) + ) + with make_client() as client: + result = client.sessions.verify("tok") + rows = [ + (a.severity, a.code, a.fqdn, a.note, a.evidence_fqdn, a.evidence) for a in result.advisories + ] + assert rows[0][:3] == ("warning", "caa_excludes_issuer", "app.customer.com") + assert rows[0][4] == "customer.com" @respx.mock @@ -249,6 +266,7 @@ def test_the_domain_check_and_apps_blocks_run() -> None: "pk_live_abc", False, ) + assert app.tls_issuer_ca == "letsencrypt.org" def test_the_webhook_handler_block_runs_against_the_body_actually_delivered() -> None: diff --git a/tests/test_tls_issuance_advisories.py b/tests/test_tls_issuance_advisories.py new file mode 100644 index 0000000..42e49fe --- /dev/null +++ b/tests/test_tls_issuance_advisories.py @@ -0,0 +1,126 @@ +"""The TLS-issuance advisories the API grew in 2026-09 (`@dodomain/node` 0.6.0). + +The same advisory shape rides on four surfaces. These tests pin all of them, plus +the property that matters most for a consumer that has never heard of them: an +advisory is advice about the NEXT step (issuing the certificate) and never moves +`verified` or `present`. +""" + +from __future__ import annotations + +import httpx +import pytest +import respx + +from dodomain import App, InvalidResponseError, TlsIssuanceAdvisory +from dodomain.models import IntegratorSession, VerifyResult +from tests.helpers import ( + INTEGRATOR_SESSION_RESPONSE, + LEGACY_INTEGRATOR_SESSION_RESPONSE, + LEGACY_LIST_APPS_RESPONSE, + LEGACY_VERIFY_RESPONSE, + LIST_APPS_RESPONSE, + TLS_ISSUANCE_ADVISORY, + VERIFY_RESPONSE, + api, + make_client, +) + + +def test_an_advisory_parses_every_field_the_wire_carries() -> None: + advisory = TlsIssuanceAdvisory._from_api(TLS_ISSUANCE_ADVISORY) + assert advisory.code == "caa_excludes_issuer" + assert advisory.severity == "warning" + assert advisory.fqdn == "app.customer.com" + # The evidence was read on the PARENT — a CAA policy is inherited, so the name + # the advisory is about and the name it was read from are routinely different. + assert advisory.evidence_fqdn == "customer.com" + assert advisory.evidence == ('issue "digicert.com"',) + assert "letsencrypt.org" in advisory.note + + +def test_an_advisory_code_outside_the_documented_union_is_rejected() -> None: + # The vocabulary is closed server-side; a value outside it is contract drift, + # not an additive change, and must not reach a caller branching on `code`. + with pytest.raises(InvalidResponseError): + TlsIssuanceAdvisory._from_api({**TLS_ISSUANCE_ADVISORY, "code": "caa_is_weird"}) + + +def test_an_advisory_severity_outside_the_documented_union_is_rejected() -> None: + with pytest.raises(InvalidResponseError): + TlsIssuanceAdvisory._from_api({**TLS_ISSUANCE_ADVISORY, "severity": "critical"}) + + +@respx.mock +def test_verify_carries_the_advisories_without_changing_the_verdict() -> None: + respx.post(api("/api/v1/sessions/tok/verify")).mock( + return_value=httpx.Response(200, json=VERIFY_RESPONSE) + ) + with make_client() as client: + result = client.sessions.verify("tok") + assert len(result.advisories) == 1 + assert result.advisories[0].code == "caa_excludes_issuer" + # The advisory is about issuing a certificate later; the DNS verdict is + # computed without it and the CNAME record is still `present`. + assert result.verified is False + assert result.records[0].present is True + + +@respx.mock +def test_verify_records_expose_what_the_nameservers_actually_answered() -> None: + respx.post(api("/api/v1/sessions/tok/verify")).mock( + return_value=httpx.Response(200, json=VERIFY_RESPONSE) + ) + with make_client() as client: + result = client.sessions.verify("tok") + assert result.records[0].authoritative_found == ("cname.dodomain.io",) + assert result.records[0].public_found == ("cname.dodomain.io",) + # `propagating` in its healthy form: authoritative has it, public does not yet. + assert result.records[1].authoritative_found == ("dodomain-verify=abc",) + assert result.records[1].public_found == () + + +@respx.mock +def test_the_authed_session_read_carries_the_last_pass_advisories() -> None: + 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 len(session.tls_issuance_advisories) == 1 + assert session.tls_issuance_advisories[0].evidence_fqdn == "customer.com" + + +@respx.mock +def test_an_app_reports_the_ca_its_certificates_are_issued_with() -> None: + respx.get(api("/api/v1/apps")).mock(return_value=httpx.Response(200, json=LIST_APPS_RESPONSE)) + with make_client() as client: + app = client.apps.list()[0] + assert app.tls_issuer_ca == "letsencrypt.org" + + +# ── tolerance: bodies recorded before these fields existed ────────────────── + + +def test_a_verify_response_recorded_before_advisories_existed_still_parses() -> None: + result = VerifyResult._from_api(LEGACY_VERIFY_RESPONSE) + assert result.advisories == () + assert result.records[0].authoritative_found == () + assert result.records[0].public_found == () + + +def test_a_session_read_recorded_before_advisories_existed_still_parses() -> None: + session = IntegratorSession._from_api(LEGACY_INTEGRATOR_SESSION_RESPONSE) + assert session.tls_issuance_advisories == () + + +def test_an_app_recorded_before_the_issuer_ca_existed_still_parses() -> None: + app = App._from_api(LEGACY_LIST_APPS_RESPONSE["apps"][0]) + assert app.tls_issuer_ca is None + + +def test_an_advisories_field_that_is_present_but_not_an_array_is_still_fatal() -> None: + # Absence is history; a wrong type is drift. The distinction is the whole + # point of `_opt_list`, so it gets its own test rather than being assumed. + with pytest.raises(InvalidResponseError): + VerifyResult._from_api({**VERIFY_RESPONSE, "advisories": "none"}) diff --git a/tests/test_version.py b/tests/test_version.py index 3be1fe7..13ddeba 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -4,7 +4,7 @@ def test_version_is_the_single_source_of_truth() -> None: - assert dodomain.__version__ == "0.3.0" + assert dodomain.__version__ == "0.4.0" def test_the_user_agent_reports_that_same_version() -> None: