From d2b8bec1091f77eadac8708ebf218f2a68771725 Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Fri, 21 Aug 2026 01:31:38 +0100 Subject: [PATCH] feat(keys): opt-in rotation overlap window and previousKeyExpiresAt parity (0.3.0) --- CHANGELOG.md | 31 +++++++++++ README.md | 33 ++++++++++-- src/dodomain/__init__.py | 4 +- src/dodomain/_validation.py | 29 +++++++++- src/dodomain/models.py | 30 +++++++++-- src/dodomain/resources/keys.py | 59 ++++++++++++++++----- tests/helpers.py | 15 ++++++ tests/test_async_parity.py | 12 +++++ tests/test_keys.py | 97 ++++++++++++++++++++++++++++++++-- tests/test_readme_examples.py | 12 +++++ tests/test_validation.py | 26 +++++++++ tests/test_version.py | 2 +- 12 files changed, 321 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ffeeec..b721e29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,37 @@ Notable changes to `dodomain-sdk`. The import package is `dodomain`. +## 0.3.0 + +Parity with the rotation-overlap contract the API shipped on 2026-08-20 (and +`@dodomain/node` 0.4.0). Purely additive: `keys.rotate()` with no argument sends +the identical body-less request it always did and still means an immediate +cutover, so upgrading from 0.2.0 is a drop-in. + +### Added + +* **`keys.rotate(overlap_hours=...)`** — `0` (the default), `1` or `24`. A window + keeps the **old key authenticating alongside the new one** until it closes, so + a rotator can deploy the new key with zero downtime instead of racing its own + cutover. Only a requested window puts a body on the wire; the default stays the + request every server version has always accepted. A value the API does not + offer is refused locally with `InvalidRequestError` and `status_code == 0`, + before the call that would mint a credential — including `True`, which `== 1` + would otherwise have bought as an unasked-for one-hour window. +* **`RotatedSecretKey.previous_key_expires_at`** — when the previous key stops + authenticating, or `None` if it already has. Parses tolerantly, so a response + recorded before the field existed still reads as a zero-overlap rotation. +* **`RotationOverlapHours`** — the `Literal[0, 1, 24]` alias, exported so callers + can type a configured window rather than pass a bare `int`. + +### Changed + +* The docs for `keys.rotate` no longer say rotation has no grace window + unconditionally: the **default** has none, and it is also the kill switch — a + zero-overlap rotation terminates a window still running from an earlier one. + **Exactly one previous key is ever kept**, so rotating twice in a row kills key + n-1 immediately regardless of its remaining window. + ## 0.2.0 Full parity with the API's current `/v1` surface: every callable REST operation diff --git a/README.md b/README.md index ee0b7ae..f816e0b 100644 --- a/README.md +++ b/README.md @@ -236,13 +236,36 @@ Three things that will bite if assumed away: 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 +rotated.previous_key_expires_at # None: the old key is already dead ``` -**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. +**The default is an immediate cutover.** 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. + +### Rotating with no downtime + +If you cannot deploy the new key in the same breath, ask for an overlap window +and **both keys authenticate** until it closes: + +```python +rotated = client.keys.rotate(overlap_hours=24) # 0 (default), 1 or 24 +rotated.previous_key_expires_at # when the OLD key stops working +``` + +Rotate, ship `rotated.secret_key` everywhere, and let the old one lapse on its +own. Three things to hold on to: + +* **Exactly one previous key is ever kept.** Rotating again overwrites that slot + and kills key n-1 immediately, whatever was left of its window — so the safe + rhythm is rotate, deploy, *then* rotate again, never two rotations in a row. +* **A zero-overlap rotation is the kill switch.** `keys.rotate()` with the + default also terminates a window still running from an earlier rotation, which + is how you revoke a previous key early. +* The client you called it on keeps using the key it was built with, so within a + window it keeps working; past the window its next call is a `401`. 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 diff --git a/src/dodomain/__init__.py b/src/dodomain/__init__.py index 39d226d..013f11a 100644 --- a/src/dodomain/__init__.py +++ b/src/dodomain/__init__.py @@ -20,7 +20,7 @@ from __future__ import annotations -__version__ = "0.2.0" +__version__ = "0.3.0" from ._client import AsyncDoDomain, DoDomain from ._transport import DEFAULT_BASE_URL, RateLimitSnapshot @@ -62,6 +62,7 @@ PublicSession, ReverifyResult, RotatedSecretKey, + RotationOverlapHours, Session, SessionWarning, Tier, @@ -115,6 +116,7 @@ "RateLimitSnapshot", "ReverifyResult", "RotatedSecretKey", + "RotationOverlapHours", "Session", "SessionWarning", "Tier", diff --git a/src/dodomain/_validation.py b/src/dodomain/_validation.py index 84887fe..20d5f93 100644 --- a/src/dodomain/_validation.py +++ b/src/dodomain/_validation.py @@ -15,9 +15,9 @@ from urllib.parse import urlsplit from .errors import InvalidRequestError -from .models import RECORD_TYPES, DnsRecord +from .models import OVERLAP_HOURS_VALUES, RECORD_TYPES, DnsRecord -__all__ = ["validate_create_session"] +__all__ = ["validate_create_session", "validate_overlap_hours"] # Two-or-more DNS labels, <=63 chars each, no leading/trailing hyphen. Case is # accepted as sent (DNS is case-insensitive) but a trailing root dot is not: the @@ -73,6 +73,31 @@ def validate_return_url(return_url: str) -> str: return value +def validate_overlap_hours(overlap_hours: object) -> int: + """Return the requested rotation overlap, or raise before any request is sent. + + The API takes ``0 | 1 | 24`` and nothing else, so an arbitrary number is a + mistake worth naming here rather than as a server-side 400 — this call mints + a credential, and the least useful moment to learn the argument was wrong is + while wondering whether the old key is still alive. + + ``True`` is refused despite ``True == 1``: a boolean here is a caller who + meant "yes, overlap" and would silently get a one-hour window they never + named. + """ + if isinstance(overlap_hours, bool) or not isinstance(overlap_hours, int): + raise _reject( + f"overlap_hours must be one of {OVERLAP_HOURS_VALUES} " + f"(got {type(overlap_hours).__name__})." + ) + if overlap_hours not in OVERLAP_HOURS_VALUES: + raise _reject( + f"overlap_hours must be one of {OVERLAP_HOURS_VALUES}; " + f"{overlap_hours} is not a window doDomain offers." + ) + return overlap_hours + + def validate_records(records: Sequence[DnsRecord] | Iterable[DnsRecord]) -> list[dict[str, object]]: """Return the wire shape of a session's records, or raise. diff --git a/src/dodomain/models.py b/src/dodomain/models.py index 79f435f..a845981 100644 --- a/src/dodomain/models.py +++ b/src/dodomain/models.py @@ -45,6 +45,7 @@ "PublicSession", "ReverifyResult", "RotatedSecretKey", + "RotationOverlapHours", "Session", "SessionWarning", "Tier", @@ -64,10 +65,20 @@ ApexToken = Literal["@", "(blank)", "%domain%"] WarningCode = Literal["duplicate_host_label"] +#: 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 +#: ``packages/core/src/schemas.ts``; the server refuses anything else. +RotationOverlapHours = Literal[0, 1, 24] + #: Every DNS record type a connect session may request, from the app repo's one #: record-type home (``packages/core/src/record-capabilities.ts``). RECORD_TYPES: tuple[str, ...] = ("A", "AAAA", "CNAME", "TXT", "MX") +#: 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) + # ── payload readers ───────────────────────────────────────────────────────── @@ -916,10 +927,15 @@ def _from_api(cls, payload: Any) -> DeletedWebhookEndpoint: 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:`secret_key` is the only copy that will ever exist, whichever overlap you + asked for. With the default immediate cutover the previous key stopped + authenticating the instant this response was produced, so a caller that drops + the new one has locked itself out of the API and must rotate again from the + dashboard. + + :attr:`previous_key_expires_at` is the whole difference an overlap window + makes: ``None`` when the old key is already dead, otherwise the moment it + stops authenticating. :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 @@ -932,6 +948,11 @@ class RotatedSecretKey: #: that prints this object must not put a live credential in your logs. secret_key: str = field(repr=False) rotated_at: datetime + #: When the PREVIOUS key stops authenticating, or ``None`` if it already has + #: (a zero-overlap rotation, which is the default). Only ever one previous + #: key exists — a later rotation replaces it and kills key n-1 immediately. + #: Also ``None`` on a response recorded before the field existed. + previous_key_expires_at: datetime | None = None raw: dict[str, Any] | None = field(default=None, compare=False, repr=False) @classmethod @@ -942,5 +963,6 @@ def _from_api(cls, payload: Any) -> RotatedSecretKey: public_key=_req_str(data, "publicKey"), secret_key=_req_str(data, "secretKey"), rotated_at=_req_datetime(data, "rotatedAt"), + previous_key_expires_at=_opt_datetime(data, "previousKeyExpiresAt"), raw=data, ) diff --git a/src/dodomain/resources/keys.py b/src/dodomain/resources/keys.py index 31179d5..d175977 100644 --- a/src/dodomain/resources/keys.py +++ b/src/dodomain/resources/keys.py @@ -19,14 +19,28 @@ from typing import TYPE_CHECKING from dodomain._transport import RequestSpec -from dodomain.models import RotatedSecretKey +from dodomain._validation import validate_overlap_hours +from dodomain.models import RotatedSecretKey, RotationOverlapHours if TYPE_CHECKING: # pragma: no cover from dodomain._client import AsyncDoDomain, DoDomain __all__ = ["AsyncKeys", "Keys"] -_ROTATE_SPEC = RequestSpec("POST", "/api/v1/keys/rotate") +_ROTATE_PATH = "/api/v1/keys/rotate" + + +def _rotate_spec(overlap_hours: RotationOverlapHours) -> RequestSpec: + """Build the rotate call, sending a body only when a window was asked for. + + The default cutover goes on the wire as **no body at all** — byte for byte + the request every server version has ever accepted, including the ones that + predate ``overlapHours``. Only a requested window sends the newer body, so + the common call cannot be broken by a server that has not shipped the field. + """ + hours = validate_overlap_hours(overlap_hours) + body = None if hours == 0 else {"overlapHours": hours} + return RequestSpec("POST", _ROTATE_PATH, json_body=body) class Keys: @@ -35,14 +49,28 @@ class Keys: def __init__(self, client: DoDomain) -> None: self._client = client - def rotate(self) -> RotatedSecretKey: + def rotate(self, *, overlap_hours: RotationOverlapHours = 0) -> 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. + The response is the only copy of the replacement whichever window you + choose. 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. + + **The default is an immediate cutover.** With ``overlap_hours=0`` the key + you authenticated this very call with stops working the instant the + response is produced. That is also the kill switch: a zero-overlap + rotation **terminates an overlap window still running** from an earlier + rotation, so it is how you revoke a previous key early. + + **An overlap window is opt-in.** ``overlap_hours=1`` or ``24`` keeps the + old key authenticating alongside the new one until + ``result.previous_key_expires_at``, which is how you deploy a new key with + no downtime: rotate, ship the new key everywhere, and let the old one + lapse. **Exactly one previous key is ever kept** — rotating again + overwrites that slot and key n-1 dies immediately, whatever was left of + its window. So the safe rhythm is rotate, deploy, *then* rotate again, not + two rotations in a row. ``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 @@ -51,14 +79,21 @@ def rotate(self) -> RotatedSecretKey: 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. + worse than one that fails loudly. Within an overlap window that client + keeps working until the window closes; without one its next call is a 401. + + Args: + overlap_hours: ``0`` (default), ``1`` or ``24``. Anything else is + refused locally, before the request that would mint a key. Raises: + InvalidRequestError: With ``status_code == 0``, when ``overlap_hours`` + is not a window the API offers. 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)) + return RotatedSecretKey._from_api(self._client.request(_rotate_spec(overlap_hours))) class AsyncKeys: @@ -67,6 +102,6 @@ class AsyncKeys: def __init__(self, client: AsyncDoDomain) -> None: self._client = client - async def rotate(self) -> RotatedSecretKey: + async def rotate(self, *, overlap_hours: RotationOverlapHours = 0) -> RotatedSecretKey: """Rotate the calling app's secret key. See :meth:`Keys.rotate`.""" - return RotatedSecretKey._from_api(await self._client.request(_ROTATE_SPEC)) + return RotatedSecretKey._from_api(await self._client.request(_rotate_spec(overlap_hours))) diff --git a/tests/helpers.py b/tests/helpers.py index 4ce6c21..6d154d1 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -193,11 +193,26 @@ def webhook_endpoint_with_secret(**overrides: Any) -> dict[str, Any]: return webhook_endpoint(**overrides) +#: A default (zero-overlap) rotation: `previousKeyExpiresAt` is present and null +#: because the old key is already dead. 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", + "previousKeyExpiresAt": None, +} + +#: The same rotation asked for a 24h window — the only field that differs. +ROTATED_KEY_WITH_OVERLAP_RESPONSE: dict[str, Any] = { + **ROTATED_KEY_RESPONSE, + "previousKeyExpiresAt": "2026-08-18T12:00:00.000Z", +} + +#: A rotate response exactly as the API shipped it before overlap windows +#: existed — the shape a cached or archived payload still has. +LEGACY_ROTATED_KEY_RESPONSE: dict[str, Any] = { + key: value for key, value in ROTATED_KEY_RESPONSE.items() if key != "previousKeyExpiresAt" } diff --git a/tests/test_async_parity.py b/tests/test_async_parity.py index bb886fa..095ae4d 100644 --- a/tests/test_async_parity.py +++ b/tests/test_async_parity.py @@ -25,6 +25,7 @@ LIST_APPS_RESPONSE, PUBLIC_SESSION_RESPONSE, ROTATED_KEY_RESPONSE, + ROTATED_KEY_WITH_OVERLAP_RESPONSE, VERIFY_RESPONSE, api, connection, @@ -241,6 +242,17 @@ lambda c: c.keys.rotate(), lambda c: c.keys.rotate(), ), + ( + "keys.rotate(overlap_hours=24)", + lambda: ( + respx.post(api("/api/v1/keys/rotate")).mock( + return_value=httpx.Response(200, json=ROTATED_KEY_WITH_OVERLAP_RESPONSE) + ) + and None + ), + lambda c: c.keys.rotate(overlap_hours=24), + lambda c: c.keys.rotate(overlap_hours=24), + ), ] diff --git a/tests/test_keys.py b/tests/test_keys.py index 58d3dc7..4ff8f2f 100644 --- a/tests/test_keys.py +++ b/tests/test_keys.py @@ -2,14 +2,23 @@ from __future__ import annotations +import json 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 +from dodomain import InvalidRequestError, PermissionError_ +from tests.helpers import ( + LEGACY_ROTATED_KEY_RESPONSE, + ROTATED_KEY_RESPONSE, + ROTATED_KEY_WITH_OVERLAP_RESPONSE, + TEST_JWT, + api, + make_async_client, + make_client, +) ROTATE = api("/api/v1/keys/rotate") @@ -35,8 +44,10 @@ def test_the_public_key_comes_back_unchanged_so_ci_can_assert_which_app_it_rewro @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. + # The app is implicit in the key — there is nothing to send that would let a + # caller name a DIFFERENT app to rotate. The default cutover also stays + # byte-for-byte the request every server version has always accepted, which + # is why an omitted overlap must not become `{"overlapHours": 0}` on the wire. route = respx.post(ROTATE).mock(return_value=httpx.Response(200, json=ROTATED_KEY_RESPONSE)) with make_client() as client: client.keys.rotate() @@ -45,6 +56,84 @@ def test_rotate_sends_a_body_less_post_with_the_callers_own_credential() -> None assert request.headers["authorization"] == "Bearer dd_sk_test_0123456789" +@respx.mock +def test_an_explicit_zero_overlap_is_the_same_body_less_request_as_the_default() -> None: + # Spelling out the default must not take a different code path from omitting + # it: `overlap_hours=0` IS the immediate cutover, and the kill switch that + # ends a window an earlier rotation opened. + route = respx.post(ROTATE).mock(return_value=httpx.Response(200, json=ROTATED_KEY_RESPONSE)) + with make_client() as client: + client.keys.rotate(overlap_hours=0) + assert route.calls[0].request.content == b"" + + +@respx.mock +@pytest.mark.parametrize("hours", [1, 24]) +def test_a_requested_window_is_the_only_thing_that_puts_a_body_on_the_wire(hours: int) -> None: + route = respx.post(ROTATE).mock( + return_value=httpx.Response(200, json=ROTATED_KEY_WITH_OVERLAP_RESPONSE) + ) + with make_client() as client: + client.keys.rotate(overlap_hours=hours) # type: ignore[arg-type] + request = route.calls[0].request + assert json.loads(request.content) == {"overlapHours": hours} + assert request.headers["content-type"] == "application/json" + + +@respx.mock +def test_a_zero_overlap_rotation_reports_no_surviving_previous_key() -> 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.previous_key_expires_at is None + + +@respx.mock +def test_an_overlap_rotation_reports_when_the_previous_key_stops_authenticating() -> None: + respx.post(ROTATE).mock( + return_value=httpx.Response(200, json=ROTATED_KEY_WITH_OVERLAP_RESPONSE) + ) + with make_client() as client: + rotated = client.keys.rotate(overlap_hours=24) + assert rotated.previous_key_expires_at == datetime(2026, 8, 18, 12, 0, tzinfo=timezone.utc) + + +@respx.mock +def test_a_response_recorded_before_overlap_windows_existed_still_parses() -> None: + # The field is additive: a payload from a server that predates it must not + # fail, and the honest answer for it is the same as a zero-overlap rotation. + respx.post(ROTATE).mock(return_value=httpx.Response(200, json=LEGACY_ROTATED_KEY_RESPONSE)) + with make_client() as client: + rotated = client.keys.rotate() + assert rotated.previous_key_expires_at is None + assert rotated.secret_key == "dd_sk_live_the_new_one" + + +@respx.mock +@pytest.mark.parametrize("hours", [2, -1, 25, 0.0, "24", None, True]) +def test_a_window_the_api_does_not_offer_is_refused_before_a_key_is_minted(hours: object) -> None: + # Rotating mints a credential, so a bad argument must never reach the server: + # the least useful moment to learn it was wrong is while wondering whether + # the old key is still alive. `True` is in here because `True == 1` would + # otherwise buy a one-hour window nobody asked for. + route = respx.post(ROTATE).mock(return_value=httpx.Response(200, json=ROTATED_KEY_RESPONSE)) + with pytest.raises(InvalidRequestError) as excinfo, make_client() as client: + client.keys.rotate(overlap_hours=hours) # type: ignore[arg-type] + assert excinfo.value.status_code == 0 + assert route.call_count == 0 + + +@respx.mock +async def test_the_async_twin_sends_the_same_window_and_reads_the_same_expiry() -> None: + route = respx.post(ROTATE).mock( + return_value=httpx.Response(200, json=ROTATED_KEY_WITH_OVERLAP_RESPONSE) + ) + async with make_async_client() as client: + rotated = await client.keys.rotate(overlap_hours=1) + assert json.loads(route.calls[0].request.content) == {"overlapHours": 1} + assert rotated.previous_key_expires_at == datetime(2026, 8, 18, 12, 0, tzinfo=timezone.utc) + + @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)) diff --git a/tests/test_readme_examples.py b/tests/test_readme_examples.py index 6e1512a..7b1351c 100644 --- a/tests/test_readme_examples.py +++ b/tests/test_readme_examples.py @@ -26,6 +26,7 @@ LIST_APPS_RESPONSE, PUBLIC_SESSION_RESPONSE, ROTATED_KEY_RESPONSE, + ROTATED_KEY_WITH_OVERLAP_RESPONSE, VERIFY_RESPONSE, api, connection, @@ -204,6 +205,17 @@ def test_the_key_rotation_block_runs() -> None: rotated = client.keys.rotate() assert rotated.secret_key.startswith("dd_sk_") assert rotated.public_key == "dd_pk_live_abc" + assert rotated.previous_key_expires_at is None + + +@respx.mock +def test_the_zero_downtime_rotation_block_runs() -> None: + respx.post(api("/api/v1/keys/rotate")).mock( + return_value=httpx.Response(200, json=ROTATED_KEY_WITH_OVERLAP_RESPONSE) + ) + with make_client() as client: + rotated = client.keys.rotate(overlap_hours=24) + assert rotated.previous_key_expires_at is not None @respx.mock diff --git a/tests/test_validation.py b/tests/test_validation.py index b198fcd..22892c5 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -8,6 +8,7 @@ from dodomain._validation import ( validate_create_session, validate_domain, + validate_overlap_hours, validate_records, validate_return_url, ) @@ -95,3 +96,28 @@ def test_a_valid_body_contains_only_the_fields_that_were_supplied() -> None: "domain": "app.customer.com", "records": [{"type": "CNAME", "host": "app", "value": "cname.dodomain.io"}], } + + +@pytest.mark.parametrize("value", [0, 1, 24]) +def test_the_three_rotation_windows_the_api_offers_are_returned_unchanged(value: int) -> None: + assert validate_overlap_hours(value) == value + + +@pytest.mark.parametrize("value", [2, 12, 25, -1, 48]) +def test_a_rotation_window_the_api_does_not_offer_is_rejected(value: int) -> None: + with pytest.raises(InvalidRequestError): + validate_overlap_hours(value) + + +@pytest.mark.parametrize("value", [True, False]) +def test_a_boolean_overlap_is_rejected_despite_being_an_int_in_python(value: bool) -> None: + # `True == 1` and `False == 0`, so a plain membership test would quietly read + # `overlap_hours=True` as a one-hour window the caller never named. + with pytest.raises(InvalidRequestError): + validate_overlap_hours(value) + + +@pytest.mark.parametrize("value", [None, "24", 24.0, object()]) +def test_a_non_integer_overlap_is_rejected(value: object) -> None: + with pytest.raises(InvalidRequestError): + validate_overlap_hours(value) diff --git a/tests/test_version.py b/tests/test_version.py index 1ac0cd8..3be1fe7 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.2.0" + assert dodomain.__version__ == "0.3.0" def test_the_user_agent_reports_that_same_version() -> None: