diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 75e5a6f6d..19ddb7f93 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,7 @@ on: - conductor/reporting-frozen-account-feed-b23 - conductor/reporting-schema-proof-receipt-diagnostics-hardening - conductor/reporting-production-tier-capabilities-b24 + - conductor/reporting-adcp-rc4-adoption # Default @adcp/sdk runner alias for storyboard jobs. Tracks the current # stable @adcp/sdk release via the ``latest`` npm dist-tag. diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml index b1be01602..5e2f515e0 100644 --- a/.github/workflows/pr-title-check.yml +++ b/.github/workflows/pr-title-check.yml @@ -3,7 +3,7 @@ name: PR Title Check on: pull_request: types: [opened, edited, synchronize, reopened] - branches: [main, conductor/1167b2-durable-managed-reporting, conductor/reporting-receipt-ingress-b22, conductor/reporting-frozen-account-feed-b23, conductor/reporting-schema-proof-receipt-diagnostics-hardening, conductor/reporting-production-tier-capabilities-b24] + branches: [main, conductor/1167b2-durable-managed-reporting, conductor/reporting-receipt-ingress-b22, conductor/reporting-frozen-account-feed-b23, conductor/reporting-schema-proof-receipt-diagnostics-hardening, conductor/reporting-production-tier-capabilities-b24, conductor/reporting-adcp-rc4-adoption] permissions: contents: read diff --git a/CLAUDE.md b/CLAUDE.md index 79ac084a7..a1f63d77a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,19 +61,22 @@ All other source code should import from `adcp.types` (the public API). ## ctx_metadata: write-only credentials prohibited -`RequestContext.metadata` (populated from the wire request's `context` extension) -is **echoed back into responses** per the AdCP context-echo contract. Adopters who -treat `metadata` as a generic KV bucket and store a credential there will discover -it round-trips to the buyer — and lands in the idempotency replay cache. - -The dispatcher fail-closes on credential-shaped keys at `_build_request_context`. +`RequestContext.metadata` is for non-secret request hints, not credentials. The +standard auth context factory adds framework fields and adopter-supplied principal +metadata. Buyer wire `context` is echoed separately; the framework does not project +it into `ToolContext.metadata`. An adopter's custom context factory or response code +can still expose secrets if it copies credentials into metadata or response context. + +The dispatcher performs **best-effort key screening** at `_build_request_context` +as defense in depth against adopter mistakes. Passing this screen does not establish +that metadata is safe to expose or that it contains no credentials. If you see a `ValueError` like `ctx_metadata may not contain credential-shaped keys`, migrate the value to `AuthInfo.credential` or a typed credential class. -**Wrong** — credential stored in metadata, round-trips into response context: +**Wrong** — credential stored in metadata; this key is rejected at dispatch: ```python -ctx = RequestContext(metadata={"upstream.api_token": secret}) # ValueError +ctx = RequestContext(metadata={"upstream.api_token": secret}) # Rejected at dispatch ``` **Right** — credential stored in the typed `AuthInfo.credential` field: @@ -90,9 +93,13 @@ ctx = RequestContext(auth_info=auth, metadata={"correlation_id": "req_xyz"}) The credential-shaped key suffix list is in `adcp.decisioning.dispatch._CREDENTIAL_SHAPED_KEY_SUFFIXES` and matches -case-insensitively at any nesting depth: `credential`, `credentials`, `token`, +case-insensitively through nested dictionaries and lists: `credential`, `credentials`, `token`, `secret`, `api_key`, `apikey`, `password`, `bearer`. Keys that don't match -(`correlation_id`, `feature_flag.beta_pricing`, `trace_id`) pass through. +(`correlation_id`, `feature_flag.beta_pricing`, `trace_id`, `tokenizer`) pass through. +This finite suffix list misses other credential names, including plural or embedded +forms such as `api_tokens` and `access_token_value`, and names such as `private_key` +and `authorization`. Other containers, including tuple values, are not traversed. +An unrecognized key or container is not permission to store a secret in metadata. For credentials the framework propagates to upstream calls (governance agents, signal providers, audience activations), use the typed credential classes from diff --git a/scripts/post_generate_fixes.py b/scripts/post_generate_fixes.py index 83b41f1c9..38e1c3c45 100644 --- a/scripts/post_generate_fixes.py +++ b/scripts/post_generate_fixes.py @@ -2593,6 +2593,162 @@ def fix_unchanged_literal_defaults() -> None: print(" No unchanged field defaults needed fixing") +def fix_reporting_request_selectors() -> None: + """Preserve reporting selector modes that codegen flattens into one model. + + A const is not a default for an alternative selector. Likewise an + aggregate default is not legal in an exact-revision request. Keep these + contracts on the generated models, including any self-contained clones, + so nesting and non-client serialization obey the same rules. + """ + scope = json.loads((SCHEMA_DIR / "core/reporting-delivery-config.json").read_text())[ + "properties" + ]["scope"] + if ( + set(scope["properties"]) != {"all_media_buys", "media_buy_ids"} + or scope["minProperties"] != 1 + or scope["maxProperties"] != 1 + or scope["properties"]["all_media_buys"] != {"type": "boolean", "const": True} + ): + raise ValueError("reporting scope schema changed; revisit selector repair") + delivery = json.loads( + (SCHEMA_DIR / "media-buy/get-media-buy-delivery-request.json").read_text() + ) + exact = next( + rule + for rule in delivery["allOf"] + if rule.get("if") == {"required": ["reporting_revision_id"]} + ) + forbidden = tuple(item["required"][0] for item in exact["then"]["not"]["anyOf"]) + repaired = {"scope": 0, "exact": 0} + for path in sorted(OUTPUT_DIR.rglob("*.py")): + original = path.read_text() + if "all_media_buys:" not in original and "class GetMediaBuyDeliveryRequest" not in original: + continue + offsets = [0] + for line in original.splitlines(keepends=True): + offsets.append(offsets[-1] + len(line)) + changes: list[tuple[int, int, str]] = [] + matched = False + needs_mapping = False + for node in ast.parse(original).body: + if not isinstance(node, ast.ClassDef): + continue + fields = { + field.target.id: field + for field in node.body + if isinstance(field, ast.AnnAssign) and isinstance(field.target, ast.Name) + } + methods = {method.name for method in node.body if isinstance(method, ast.FunctionDef)} + if set(fields) == {"all_media_buys", "media_buy_ids"}: + repaired["scope"] += 1 + needs_mapping = True + matched = True + field = fields["all_media_buys"] + if field.value is None: + raise ValueError("reporting scope lost its generated default") + annotation = field.annotation + if isinstance(annotation, ast.Subscript) and isinstance( + annotation.slice, ast.Tuple + ): + annotation = annotation.slice.elts[0] + for part, replacement in ( + (annotation, "Literal[True] | None"), + (field.value, "None"), + ): + changes.append( + ( + offsets[part.lineno - 1] + part.col_offset, + offsets[part.end_lineno - 1] + part.end_col_offset, + replacement, + ) + ) + addition = f""" + @model_validator(mode='before') + @classmethod + def _select_reporting_scope(cls, value: Any) -> Any: + if not isinstance(value, Mapping): + return value + if 'all_media_buys' in value and 'media_buy_ids' in value: + raise ValueError('reporting scope requires exactly one selector') + if 'all_media_buys' in value and value['all_media_buys'] is not True: + raise ValueError('all_media_buys must be true') + if 'media_buy_ids' in value: + if value['media_buy_ids'] is None: + raise ValueError('media_buy_ids must be a nonempty unique list') + return value + # Preserve the typed empty-scope convenience without mutating its input. + return {{**value, 'all_media_buys': True}} + + @model_validator(mode='after') + def _unique_reporting_scope(self) -> {node.name}: + if self.media_buy_ids is not None: + ids = [getattr(item, 'root', item) for item in self.media_buy_ids] + if len(ids) != len(set(ids)): + raise ValueError('media_buy_ids must be unique') + return self + + @model_serializer(mode='wrap') + def _serialize_reporting_scope(self, handler: SerializerFunctionWrapHandler) -> dict[str, Any]: + value: dict[str, Any] = handler(self) + for name in ('all_media_buys', 'media_buy_ids'): + if value.get(name) is None: + value.pop(name, None) + return value +""" + marker = "_select_reporting_scope" + elif re.fullmatch(r"GetMediaBuyDeliveryRequest\d*", node.name): + if not {*forbidden, "reporting_revision_id", "pagination"} <= fields.keys(): + raise ValueError("delivery request fields changed; revisit selector repair") + repaired["exact"] += 1 + matched = True + addition = f""" + @model_validator(mode='after') + def _validate_delivery_selector_mode(self) -> {node.name}: + if self.reporting_revision_id is not None: + if self.model_fields_set.intersection({forbidden!r}): + raise ValueError('exact revision requests forbid aggregate selectors, even false or null') + elif self.pagination is not None: + raise ValueError('pagination requires reporting_revision_id') + return self + + @model_serializer(mode='wrap') + def _serialize_delivery_selector_mode(self, handler: SerializerFunctionWrapHandler) -> dict[str, Any]: + value: dict[str, Any] = handler(self) + if self.reporting_revision_id is not None: + for name in {forbidden!r}: + if name not in self.model_fields_set: + value.pop(name, None) + return value +""" + marker = "_validate_delivery_selector_mode" + else: + continue + if marker not in methods: + changes.append((offsets[node.end_lineno], offsets[node.end_lineno], addition)) + if not matched: + continue + source = original + for start, end, replacement in sorted(changes, reverse=True): + source = source[:start] + replacement + source[end:] + imports = ("from collections.abc import Mapping\n" if needs_mapping else "") + ( + "from typing import Any\n" + "from pydantic import SerializerFunctionWrapHandler, model_serializer, model_validator\n" + ) + if "from pydantic import SerializerFunctionWrapHandler," not in source: + source = source.replace( + "from __future__ import annotations\n", + "from __future__ import annotations\n\n" + imports, + 1, + ) + ast.parse(source) + if source != original: + path.write_text(source) + print(f" {path.relative_to(OUTPUT_DIR)}: reporting selector modes") + if not all(repaired.values()): + raise ValueError("expected generated reporting selector model missing") + + def fix_reporting_capability_defaults() -> None: """Keep optional reporting promises absent in both generated model graphs. @@ -6052,6 +6208,7 @@ def main(argv: list[str] | None = None): widen_extension_point_lists_to_sequence, fix_canceled_literal_defaults, fix_unchanged_literal_defaults, + fix_reporting_request_selectors, fix_reporting_capability_defaults, fix_protocol_envelope_status_default, fix_trusted_match_runtime_validators, diff --git a/src/adcp/decisioning/dispatch.py b/src/adcp/decisioning/dispatch.py index 69bd727b9..b0d9299e8 100644 --- a/src/adcp/decisioning/dispatch.py +++ b/src/adcp/decisioning/dispatch.py @@ -511,14 +511,13 @@ def _strict_validate_platform() -> bool: #: Substring suffixes that flag a ctx_metadata key as credential-shaped. #: Lowercased for case-insensitive matching against the user-supplied -#: key. The list intentionally errs broad — a key like +#: key. This finite list is best-effort screening — a key like #: ``"upstream.api_key"`` belongs in :class:`AuthInfo.credential`, not -#: ``ctx.metadata`` which round-trips into responses. +#: ``ctx.metadata``, which is reserved for non-secret request hints. #: #: Drift policy: when the spec or adopter conventions add a new -#: credential-shaped suffix, append here. The gate is fail-closed by -#: design — false positives require the adopter to rename the key, NOT -#: silently echo the credential. +#: credential-shaped suffix, evaluate its coverage and false positives before +#: adding it. A passing key is not evidence that its value is safe to expose. _CREDENTIAL_SHAPED_KEY_SUFFIXES: tuple[str, ...] = ( "credential", "credentials", @@ -532,13 +531,13 @@ def _strict_validate_platform() -> bool: def _validate_ctx_metadata_credentials(metadata: Any) -> None: - """Fail-closed gate: ctx.metadata must not carry credential-shaped - keys. + """Best-effort credential-key screening, as defense in depth. - The framework projects buyer-supplied ``context`` extensions into - ``tool_ctx.metadata`` and echoes context back on responses per - the AdCP spec. An adopter who treats ``metadata`` as a generic - KV bucket can accidentally round-trip a credential to the buyer. + Standard auth context construction adds framework fields and adopter + principal metadata. Buyer wire ``context`` is echoed separately and is + not projected into ``tool_ctx.metadata`` by the framework. An adopter's + custom context factory or response code can still expose credentials + copied into metadata. Passing this screen is not a no-credential guarantee. The ergonomic path for credentials is :class:`AuthInfo.credential` / typed credential classes (:class:`ApiKeyCredential`, :class:`OAuthCredential`, @@ -546,12 +545,12 @@ def _validate_ctx_metadata_credentials(metadata: Any) -> None: request-scope hints (correlation ids, feature flags, trace ids). Matches against any key whose lowercased form ends with one of - :data:`_CREDENTIAL_SHAPED_KEY_SUFFIXES`. Sub-keys at any nesting - depth count — a buyer-supplied - ``{"upstream": {"api_token": "..."}}`` is rejected the same as - a flat ``{"api_token": "..."}``. + :data:`_CREDENTIAL_SHAPED_KEY_SUFFIXES`, through dictionaries and lists. + Other containers are not traversed. Unlisted names, including some + plural and embedded credential terms, pass this finite suffix screen; + that does not make their contents safe for metadata or response echo. - :raises ValueError: when any credential-shaped key is found. The + :raises ValueError: when a screened key matches a listed suffix. The exception message names the offending key path so the adopter knows which field to migrate to ``AuthInfo.credential``. """ @@ -595,12 +594,11 @@ def _validate_ctx_metadata_credentials(metadata: Any) -> None: def _walk_ctx_metadata_list(items: list[Any]) -> None: - """Recurse into a list collected from ``ctx_metadata`` and reject - any credential-shaped key found in a dict element. + """Recurse into a metadata list and screen dictionary keys by suffix. Nested lists are walked through this same function. Non-dict, - non-list items (strings, numbers, None) are ignored — only - container types can hide a credential-shaped key. + non-list items (including tuple values) are ignored. This finite + traversal is best-effort screening, not a no-credential guarantee. """ for index, item in enumerate(items): if isinstance(item, dict): @@ -1233,17 +1231,11 @@ def _build_request_context( auth_principal = auth_info.principal if auth_info is not None else None - # ctx_metadata credential gate — fail-closed before any platform - # method sees the metadata. Buyers can populate ``context`` - # extensions on the wire request that the framework projects into - # ``tool_ctx.metadata``; an adopter who treats ``metadata`` as a - # general-purpose KV bucket might shove a credential through it, - # only to discover the value round-trips into the response (the - # framework echoes context into responses per the AdCP spec). - # The ergonomic path for credentials is :class:`AuthInfo.credential` - # / typed credential classes; ``metadata`` is for non-secret - # request-scope hints. See the "ctx_metadata: write-only credentials - # prohibited" section in CLAUDE.md. + # Best-effort screening of adopter metadata before platform dispatch. + # Wire context echo is separate; metadata must contain only non-secret + # request hints even when its keys pass this finite denylist. Credentials + # belong in AuthInfo.credential / typed credential classes. See the + # "ctx_metadata: write-only credentials prohibited" section in CLAUDE.md. _validate_ctx_metadata_credentials(tool_ctx.metadata) # Composite cache scope key when store is supplied (production diff --git a/src/adcp/reporting/feed/request.py b/src/adcp/reporting/feed/request.py index 7a81c3529..2eda4484d 100644 --- a/src/adcp/reporting/feed/request.py +++ b/src/adcp/reporting/feed/request.py @@ -43,12 +43,14 @@ def _semantic_numbers(value: Any) -> Any: def transport_parameters(params: dict[str, Any]) -> dict[str, Any]: - """Read ordinary JSON without rounding the raw pagination integer first. + """Normalize received JSON numbers without changing their semantic value. The shared transport decoder retains decimal lexemes for financial receipt - admission. Feed context/vendor JSON uses normal finite JSON numbers, while - A2A's exact 1.0 spelling of a page limit must remain distinguishable from a - non-integer such as 1.000000000000000000001. + admission. Feed context/vendor JSON permits ordinary finite fractions, but + conversion must not alias two different received lexemes. Integral Decimal + values become exact integers; fractional values must survive the ordinary + JSON float round trip. This cannot recover precision a client lost before + transmission (for example in a protobuf Struct). """ def convert(value: Any) -> Any: @@ -56,6 +58,14 @@ def convert(value: Any) -> Any: number = float(value) if not math.isfinite(number): raise ValueError("feed parameters require finite JSON numbers") + if value == value.to_integral_value(): + # The finite-float check above also bounds exponent expansion. + # Do not pass exact large integers through a binary float. + return int(value) + if Decimal(repr(number)) != value: + raise ValueError( + "feed fractional parameters require an exact JSON number round trip" + ) return number if type(value) is dict: return {key: convert(item) for key, item in value.items()} diff --git a/src/adcp/reporting/ledger/producer.py b/src/adcp/reporting/ledger/producer.py index 62134481e..fc54ea2b3 100644 --- a/src/adcp/reporting/ledger/producer.py +++ b/src/adcp/reporting/ledger/producer.py @@ -758,6 +758,9 @@ async def acquire_obligation( snapshot obligation. Without it a satisfied obligation is left alone, because re-reading a settled period on every worker turn would burn upstream quota to republish bytes nobody asked for. + + ``now`` freezes dispatch and the source read cutoff. Revision creation + uses a fresh producer clock sample after the staged objects are read. """ if configuration.generation_key != obligation.generation_key: raise LedgerConflictError( @@ -874,12 +877,20 @@ async def acquire_obligation( ) return None + # ``now`` freezes dispatch/lease/cutoff decisions, not publication. + # A conforming source can observe finality while acquisition is running. + published_at = self._clock() + if _utc(published_at) < _utc(now): + raise LedgerConflictError( + "PUBLICATION_TIME_INVALID", + "producer clock regressed during acquisition; correct the clock before retrying", + ) committed = await self.commit_revision_from_manifest( obligation, manifest, rows=rows, finality=finality, - now=now, + now=published_at, turn=turn, ) if checkpoint_store is not None: @@ -981,7 +992,9 @@ async def commit_revision_from_manifest( A snapshot restatement supersedes the current snapshot leaf; there is no edit path. An official close is terminal, so a later source correction - must arrive as an adjustment instead. + must arrive as an adjustment instead. ``now`` is a trusted publication + instant, unlike the dispatch instant accepted by ``acquire_obligation``. + Replaying a publication retains its original creation time and parent. """ obligation = await self._stored_obligation(obligation) self._validate_manifest_currency(obligation, manifest) @@ -1018,6 +1031,26 @@ async def commit_revision_from_manifest( control_totals = tuple((total.name, total.value) for total in manifest.control_totals) revision_id = f"rpr_{manifest.publication_id[4:44]}" + prior = next((item for item in existing if item.reporting_revision_id == revision_id), None) + created_at = prior.created_at if prior is not None else now + if prior is not None: + # Still reconstruct and verify the supplied content below. Merely + # finding the ID must not bypass immutable-content validation. + supersedes = prior.supersedes_reporting_revision_id + if ( + _utc(manifest.acquired_at) > _utc(now) + or _utc(manifest.observed_at) > _utc(created_at) + or _utc(manifest.finality_evidence.observed_at) > _utc(created_at) + or ( + finality == "official" + and _utc(manifest.finality_evidence.observed_at) < _utc(obligation.period.end) + ) + ): + raise LedgerConflictError( + "PUBLICATION_TIME_INVALID", + "source observation or finality is outside the publication time bounds; " + "check source evidence and the producer clock before retrying", + ) revision = ReportingRevisionRecord( reporting_revision_id=revision_id, account_id=obligation.account_id, @@ -1033,7 +1066,7 @@ async def commit_revision_from_manifest( control_totals=control_totals, observed_at=manifest.observed_at, data_through=manifest.data_through, - created_at=now, + created_at=created_at, supersedes_reporting_revision_id=supersedes, finality_basis=( ( diff --git a/src/adcp/reporting/materializer/pg.py b/src/adcp/reporting/materializer/pg.py index fe01e7805..18eede783 100644 --- a/src/adcp/reporting/materializer/pg.py +++ b/src/adcp/reporting/materializer/pg.py @@ -343,7 +343,12 @@ async def claim_materialization( ) result = await self._claim_account_on(connection, account_id, keys, lease_seconds) await self._schedule_account_on(connection, account_id) - return result + # A committed account turn moves its durable served_at rank. Start + # there again so continuously due peers cannot hide newly enrolled + # accounts behind this pre-update cursor. Lock misses above retain + # the continuation, allowing the next bounded page past a busy prefix. + self._materializer_sample_after = None + return result return ReportingMaterializerTurn("idle") async def _claim_account_on( diff --git a/src/adcp/server/a2a_server.py b/src/adcp/server/a2a_server.py index 91d944c0c..cc2a0c924 100644 --- a/src/adcp/server/a2a_server.py +++ b/src/adcp/server/a2a_server.py @@ -633,18 +633,25 @@ def _parse_request(self, context: RequestContext) -> tuple[str | None, dict[str, parsed = self._default_parse_request(context) except ValueError: # The protobuf JSON printer refuses non-finite numbers. - # A standard raw receipt invocation still reaches mandatory + # Standard raw receipt/feed invocations still reach their # strict preflight and a closed INVALID_REQUEST, without - # logging a protobuf serialization exception. + # logging a protobuf serialization exception. Feed recovery + # is confined to an explicitly mounted reporting store. if request is not None: from adcp.reporting.receipts.transport import ( RAW_RECEIPT_BODY_SCOPE_KEY, a2a_receipt_parameters, ) - raw = a2a_receipt_parameters(request.scope.get(RAW_RECEIPT_BODY_SCOPE_KEY)) - if raw is not None: - return "sync_reporting_receipts", raw + tasks = ["sync_reporting_receipts"] + if getattr(self._handler, "reporting_feed_store", None) is not None: + tasks.append("get_reporting_status") + for task in tasks: + raw = a2a_receipt_parameters( + request.scope.get(RAW_RECEIPT_BODY_SCOPE_KEY), task=task + ) + if raw is not None: + return task, raw raise if request is not None and ( parsed[0] == "sync_reporting_receipts" diff --git a/src/adcp/signing/verifier.py b/src/adcp/signing/verifier.py index 65f51675c..dcc7a7b7d 100644 --- a/src/adcp/signing/verifier.py +++ b/src/adcp/signing/verifier.py @@ -68,6 +68,11 @@ supports_atomic_claim, ) from adcp.signing.revocation import RevocationChecker, RevocationList +from adcp.signing.revocation_fetcher import ( + RevocationListFetchError, + RevocationListFreshnessError, + RevocationListParseError, +) CoversDigestPolicy = Literal["required", "forbidden", "either"] SigningProfileVersion = Literal["3.0", "3.1", "3.2"] @@ -331,12 +336,24 @@ def verify_request_signature( f"is in the past" ), ) - if options.revocation_checker is not None and options.revocation_checker(keyid): - raise SignatureVerificationError( - REQUEST_SIGNATURE_KEY_REVOKED, - step=9, - message=f"key {keyid!r} is revoked", - ) + if options.revocation_checker is not None: + try: + revoked = options.revocation_checker(keyid) + except (RevocationListFetchError, RevocationListParseError, RevocationListFreshnessError): + # The checker owns caching and grace. Only its documented trust / + # availability failures become signature rejections here; unrelated + # adopter errors remain operational. Never expose transport details. + raise SignatureVerificationError( + REQUEST_SIGNATURE_REVOCATION_STALE, + step=9, + message="revocation status could not be verified from a fresh, trusted list", + ) from None + if revoked: + raise SignatureVerificationError( + REQUEST_SIGNATURE_KEY_REVOKED, + step=9, + message=f"key {keyid!r} is revoked", + ) # Cheap early rejection; ``claim`` repeats the capacity check atomically # after crypto verification so concurrent claims cannot exceed the cap. diff --git a/src/adcp/signing/webhook_signer.py b/src/adcp/signing/webhook_signer.py index 4170dca31..6494536f4 100644 --- a/src/adcp/signing/webhook_signer.py +++ b/src/adcp/signing/webhook_signer.py @@ -1,10 +1,12 @@ """Signer for the AdCP webhook-signing profile (adcp#2423). -Same 9421 substrate as :func:`adcp.signing.signer.sign_request`, with three -values pinned by the webhook profile: +Same 9421 substrate as :func:`adcp.signing.signer.sign_request`, with values +pinned by the webhook profile: * ``tag`` — ``adcp/webhook-signing/v1`` * ``cover_content_digest`` — always ``True`` (body IS the event) +* ``Signature`` — unpadded Base64URL throughout 3.x, independently of the + AdCP 3.2 request-signing migration to padded standard Base64. * the signing JWK MUST have ``adcp_use: "webhook-signing"`` in the sender's published ``adagents.json``; verifying this at publish time is out of scope for the signer, but callers should enforce it when registering their keyring. @@ -39,8 +41,9 @@ def sign_webhook( ) -> SignedHeaders: """Sign an outgoing webhook POST per adcp/webhook-signing/v1. - ``cover_content_digest=True`` and ``tag=WEBHOOK_TAG`` are pinned. The - caller attaches ``SignedHeaders.as_dict()`` to the outgoing HTTP request. + ``cover_content_digest=True``, ``tag=WEBHOOK_TAG`` and unpadded Base64URL + Signature encoding are pinned. The caller attaches + ``SignedHeaders.as_dict()`` to the outgoing HTTP request. The ``method`` is normally ``"POST"`` for webhook delivery; passed through unchanged so callers signing a retried ``PUT`` or variant delivery verb @@ -65,7 +68,9 @@ def sign_webhook( nonce=nonce, tag=WEBHOOK_TAG, label=label, - signing_profile_version="3.2", + # Webhook-v1 keeps legacy Signature encoding even on AdCP 3.2 routes. + # Explicit digest coverage preserves the existing Content-Digest bytes. + signing_profile_version="3.1", ) diff --git a/src/adcp/signing/webhook_verifier.py b/src/adcp/signing/webhook_verifier.py index e122c964c..b2b8271be 100644 --- a/src/adcp/signing/webhook_verifier.py +++ b/src/adcp/signing/webhook_verifier.py @@ -24,7 +24,7 @@ from collections.abc import Callable, Mapping from dataclasses import dataclass, field -from adcp.signing.canonical import _lookup, parse_signature_input_header +from adcp.signing.canonical import _lookup, parse_signature_input_header, split_structured_field from adcp.signing.constants import ( ADCP_USE_REQUEST, ADCP_USE_WEBHOOK, @@ -37,6 +37,7 @@ from adcp.signing.errors import ( REQUEST_TO_WEBHOOK_CODE, WEBHOOK_SIGNATURE_COMPONENTS_INCOMPLETE, + WEBHOOK_SIGNATURE_HEADER_MALFORMED, WEBHOOK_SIGNATURE_INVALID, SignatureVerificationError, ) @@ -137,6 +138,7 @@ def verify_webhook_signature( code on failure. Success returns a :class:`VerifiedWebhookSender` carrying the identity to scope dedup state by. """ + _precheck_webhook_signature_alphabet(headers, options.label) _precheck_webhook_has_required_components(headers) request_options = VerifyOptions( @@ -161,6 +163,9 @@ def verify_webhook_signature( expected_key_origins=options.expected_key_origins, signing_purpose="webhook_signing", posture=options.posture, + # The rc.4 webhook-v1 corpus retains legacy Base64URL signatures. + # Do not inherit the stricter 3.2 *request* profile for this route. + signing_profile_version="3.1", ) try: @@ -179,6 +184,34 @@ def verify_webhook_signature( ) +def _precheck_webhook_signature_alphabet(headers: Mapping[str, str], label: str) -> None: + """Keep legacy decoder tolerance without accepting mixed alphabets. + + Webhook-v1 emits unpadded Base64URL throughout 3.x. The shared legacy + decoder also tolerates standard Base64, but its URL fallback would + accept a mixed token. That tolerance is not conformant emission. + Confine this profile check to the selected webhook Signature value. + Content-Digest and request/JWK/JWT decoders keep their own contracts. + """ + raw = _lookup(headers, "signature") + if raw is None: + return + for entry in split_structured_field(raw, ","): + name, separator, value = entry.strip().partition("=") + if not separator or name.strip() != label: + continue + value = value.strip() + if value.startswith(":") and value.endswith(":"): + token = value[1:-1] + if any(char in token for char in "+/") and any(char in token for char in "-_"): + raise SignatureVerificationError( + WEBHOOK_SIGNATURE_HEADER_MALFORMED, + step=1, + message="webhook Signature must not mix Base64 alphabets", + ) + return + + def _precheck_webhook_has_required_components(headers: Mapping[str, str]) -> None: """Reject before crypto if Signature-Input omits webhook-required components. diff --git a/src/adcp/types/generated_poc/core/reporting_delivery_config.py b/src/adcp/types/generated_poc/core/reporting_delivery_config.py index b16dc00c0..51a03accc 100644 --- a/src/adcp/types/generated_poc/core/reporting_delivery_config.py +++ b/src/adcp/types/generated_poc/core/reporting_delivery_config.py @@ -1,9 +1,13 @@ # generated by datamodel-codegen: # filename: core/reporting_delivery_config.json -# timestamp: 2026-09-14T14:16:02+00:00 +# timestamp: 2026-09-22T12:02:05+00:00 from __future__ import annotations +from collections.abc import Mapping +from typing import Any +from pydantic import SerializerFunctionWrapHandler, model_serializer, model_validator + from adcp.types._str_enum import StrEnum from typing import Annotated, Literal @@ -24,11 +28,43 @@ class Scope(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - all_media_buys: Literal[True] = True + all_media_buys: Literal[True] | None = None media_buy_ids: Annotated[ list[reporting_coverage.ReportingMediaBuyId] | None, Field(min_length=1) ] = None + @model_validator(mode='before') + @classmethod + def _select_reporting_scope(cls, value: Any) -> Any: + if not isinstance(value, Mapping): + return value + if 'all_media_buys' in value and 'media_buy_ids' in value: + raise ValueError('reporting scope requires exactly one selector') + if 'all_media_buys' in value and value['all_media_buys'] is not True: + raise ValueError('all_media_buys must be true') + if 'media_buy_ids' in value: + if value['media_buy_ids'] is None: + raise ValueError('media_buy_ids must be a nonempty unique list') + return value + # Preserve the typed empty-scope convenience without mutating its input. + return {**value, 'all_media_buys': True} + + @model_validator(mode='after') + def _unique_reporting_scope(self) -> Scope: + if self.media_buy_ids is not None: + ids = [getattr(item, 'root', item) for item in self.media_buy_ids] + if len(ids) != len(set(ids)): + raise ValueError('media_buy_ids must be unique') + return self + + @model_serializer(mode='wrap') + def _serialize_reporting_scope(self, handler: SerializerFunctionWrapHandler) -> dict[str, Any]: + value: dict[str, Any] = handler(self) + for name in ('all_media_buys', 'media_buy_ids'): + if value.get(name) is None: + value.pop(name, None) + return value + class CoverageRequirement(StrEnum): full = 'full' diff --git a/src/adcp/types/generated_poc/media_buy/get_media_buy_delivery_request.py b/src/adcp/types/generated_poc/media_buy/get_media_buy_delivery_request.py index d8739e564..5beaa153a 100644 --- a/src/adcp/types/generated_poc/media_buy/get_media_buy_delivery_request.py +++ b/src/adcp/types/generated_poc/media_buy/get_media_buy_delivery_request.py @@ -1,9 +1,12 @@ # generated by datamodel-codegen: # filename: media_buy/get_media_buy_delivery_request.json -# timestamp: 2026-09-13T18:52:32+00:00 +# timestamp: 2026-09-22T12:02:05+00:00 from __future__ import annotations +from typing import Any +from pydantic import SerializerFunctionWrapHandler, model_serializer, model_validator + from typing import Annotated from adcp.types.base import AdCPBaseModel @@ -539,3 +542,21 @@ class GetMediaBuyDeliveryRequest(AdcpVersionEnvelope): ] = None context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + + @model_validator(mode='after') + def _validate_delivery_selector_mode(self) -> GetMediaBuyDeliveryRequest: + if self.reporting_revision_id is not None: + if self.model_fields_set.intersection(('media_buy_ids', 'start_date', 'end_date', 'status_filter', 'requested_metrics', 'reporting_dimensions', 'attribution_window', 'include_package_daily_breakdown', 'time_granularity', 'include_window_breakdown')): + raise ValueError('exact revision requests forbid aggregate selectors, even false or null') + elif self.pagination is not None: + raise ValueError('pagination requires reporting_revision_id') + return self + + @model_serializer(mode='wrap') + def _serialize_delivery_selector_mode(self, handler: SerializerFunctionWrapHandler) -> dict[str, Any]: + value: dict[str, Any] = handler(self) + if self.reporting_revision_id is not None: + for name in ('media_buy_ids', 'start_date', 'end_date', 'status_filter', 'requested_metrics', 'reporting_dimensions', 'attribution_window', 'include_package_daily_breakdown', 'time_granularity', 'include_window_breakdown'): + if name not in self.model_fields_set: + value.pop(name, None) + return value diff --git a/tests/conformance/reporting/_late_account_server.py b/tests/conformance/reporting/_late_account_server.py new file mode 100644 index 000000000..8bcd484d3 --- /dev/null +++ b/tests/conformance/reporting/_late_account_server.py @@ -0,0 +1,438 @@ +"""Public production support starts empty; all enrollment arrives over real HTTP.""" + +import argparse +import importlib.metadata +import json +import os +from datetime import datetime, timezone +from pathlib import Path + +from psycopg_pool import AsyncConnectionPool + +import adcp +from adcp.decisioning.capabilities import Account as AccountCapabilities +from adcp.reporting.ledger import ( + ProducerOfferings, + ReportingConfiguration, + ReportingDestinationBinding, + ReportingProducer, + ReportingScheduleSpec, +) +from adcp.reporting.materializer import ( + ReportingDestinationIO, + ReportingMaterializerService, + ReportingRevisionVerifierRegistry, + ReportingWriterCapability, +) +from adcp.reporting.production import ( + PgReportingProductionStore, + ReportingConfigurationAdmission, + ReportingProductionConfigurationTask, + ReportingProductionOffering, + ReportingProductionSupport, +) +from adcp.reporting.projection import PgReportingStatusProjection +from adcp.reporting.receipts import ReportingReceiptError +from adcp.reporting.source import parse_verified_source_batch_manifest_v1 +from adcp.server import serve +from adcp.server.auth import BearerTokenAuth, Principal, auth_context_factory +from adcp.types import ReportingDeliveryOffering + +from ._generation_support import END, START +from ._late_account_support import ACCOUNTS, AccountSource, CurrencyDestination, verifier_for + +CONSUMERS = {"usd": "urn:buyer:usd", "eur": "urn:buyer:eur"} + + +class LiveAccountSource(AccountSource): + """Record the public source boundary, without changing its returned evidence.""" + + async def execute(self, request, *, cancel, heartbeat=None): + result = await super().execute(request, cancel=cancel, heartbeat=heartbeat) + if result.ok: + manifest = parse_verified_source_batch_manifest_v1( + result.response.manifest, result.manifest_bytes + ) + with self.observation_log.open("a") as output: + output.write( + json.dumps( + { + "account": request.identity.account_id, + "period": { + "start": request.period.start.isoformat(), + "end": request.period.end.isoformat(), + }, + "source_read_cutoff_at": ( + request.period.source_read_cutoff_at.isoformat() + ), + "observed_at": manifest.observed_at.isoformat(), + "acquired_at": manifest.acquired_at.isoformat(), + "finalized_at": manifest.finality_evidence.observed_at.isoformat(), + "data_through": manifest.data_through.isoformat(), + "publication_id": manifest.publication_id, + } + ) + + "\n" + ) + return result + + +class WireCapture: + def __init__(self, app, path): + self.app, self.path = app, Path(path) + + async def __call__(self, scope, receive, send): + if scope["type"] != "http" or scope.get("method") != "POST": + return await self.app(scope, receive, send) + request, response = bytearray(), bytearray() + status = None + + async def incoming(): + message = await receive() + if message["type"] == "http.request": + request.extend(message.get("body", b"")) + return message + + async def outgoing(message): + nonlocal status + if message["type"] == "http.response.start": + status = message["status"] + if message["type"] == "http.response.body": + response.extend(message.get("body", b"")) + await send(message) + + try: + await self.app(scope, incoming, outgoing) + finally: + assert len(request) < 2_000_000 and len(response) < 4_000_000 + with self.path.open("a") as output: + output.write( + json.dumps( + { + "path": scope["path"], + "status": status, + "request_utf8": request.decode(), + "response_utf8": response.decode(), + } + ) + + "\n" + ) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--port", type=int, required=True) + parser.add_argument("--root", type=Path, required=True) + parser.add_argument("--schema", required=True) + parser.add_argument("--notifications", action="store_true") + parser.add_argument("--live-source-observation", action="store_true") + args = parser.parse_args() + root = args.root + pool = AsyncConnectionPool( + os.environ["ADCP_PG_TEST_URL"], + kwargs={ + "autocommit": True, + "application_name": args.schema, + "options": f"-csearch_path={args.schema} -cstatement_timeout=15000", + }, + min_size=1, + max_size=1, + open=False, + ) + store = PgReportingProductionStore(pool=pool, notifications=args.notifications) + capability = ReportingWriterCapability( + "warehouse_materialization", + "fixture-sql", + "jsonl", + "canonical_digest", + "destination", + "immutable_location", + "sha256", + "conditional_create", + ) + verifiers = {currency: verifier_for(currency, capability) for currency in ("USD", "EUR")} + writer = CurrencyDestination( + root / "destination.sqlite", tuple(v.key for v in verifiers.values()) + ) + registry = ReportingRevisionVerifierRegistry(tuple(verifiers.values())) + sources, producers, offerings = {}, {}, {} + # The runtime fairness test keeps a historical source observation. The + # separate publication-time test selects a real UTC sample after fetch. + # Production turns and PostgreSQL lease scheduling always use real clocks. + source_observed_at = datetime.now(timezone.utc) + for currency, verifier in verifiers.items(): + key = verifier.key + source_class = LiveAccountSource if args.live_source_observation else AccountSource + source = source_class( + key, + root / ("source-" + currency), + clock=( + (lambda: datetime.now(timezone.utc)) + if args.live_source_observation + else (lambda: source_observed_at) + ), + official=True, + ) + if args.live_source_observation: + source.observation_log = root / "source-observations.jsonl" + producer = ReportingProducer( + source=source, + store=store, + offerings=ProducerOfferings( + official_offering_id=source.source_id, + publication_namespace=source.capabilities.offerings[0].publication_namespace, + source_scope=source.capabilities.source_scope, + ), + object_reader=source.reader, + revision_verifier=verifier, + # Real clock, bounded catch-up: two actual publications per producer + # turn keep pending work behind a materializer that serves one turn. + # Public reads pin a delivered period while later periods provide + # continuing production load; no scheduler call is injected. + max_periods_per_turn=2, + currency_resolver=lambda config, obligation: ACCOUNTS[config.account_id][0], + ) + profile = { + "id": key.reporting_profile, + "version": key.definition.schema_version, + "schema_uri": key.definition.schema_uri, + "schema_sha256": key.definition.schema_sha256, + "schema_dialect": key.definition.schema_dialect, + "schema_ref_policy": key.definition.schema_ref_policy, + "grain": "row", + "primary_keys": ["row_id"], + "canonicalization_id": key.canonicalization.canonicalization_id, + "canonicalization_uri": key.canonicalization.canonicalization_uri, + "canonicalization_sha256": key.canonicalization.canonicalization_sha256, + } + public_offering = ReportingDeliveryOffering.model_validate( + { + "offering_id": "official-" + currency, + "feed_purpose": "billing", + "report_definition_id": key.report_definition_id, + "report_definition_uri": key.definition.report_definition_uri, + "report_definition_sha256": key.definition.report_definition_sha256, + "reporting_profile": profile, + "schedule": {"period_duration": "PT1H", "alignment": "utc", "delivery_sla": "PT1H"}, + "supported_finality": ["official"], + "reconciliation_mode": "consumer_receipt", + "method": writer.delivery_methods[0].wire(), + } + ) + sources[currency], producers[currency] = source, producer + offerings[currency] = ReportingProductionOffering( + public_offering, producer, key, source.source_id + ) + + def config_for(account): + key = verifiers[ACCOUNTS[account][0]].key + return ReportingConfiguration( + "shared-config", + 1, + account, + key.report_definition_id, + key.reporting_profile, + "billing", + ReportingScheduleSpec("PT1H", "PT1H", period_anchor=START), + "official", + activated_at=START, + media_buy_ids=("shared-media-buy",), + definition=key.definition, + ) + + def binding_for(config): + return ReportingDestinationBinding( + config.generation_key, + CONSUMERS[config.account_id], + "shared-destination", + "trusted-" + config.account_id, + capability.method, + capability.transport, + capability.verification_profile, + "consumer_receipt", + "billing", + 400, + START, + capability.format, + ("fixture-sql-v1",), + "delivered", + ) + + def wire_for(config, binding): + offering = offerings[ACCOUNTS[config.account_id][0]] + key = verifiers[ACCOUNTS[config.account_id][0]].key + return { + "delivery_config_id": config.delivery_config_id, + "delivery_config_version": 1, + "offering_id": offering.offering_id, + "active": True, + "feed_purpose": "billing", + "report_definition_id": key.report_definition_id, + "reporting_profile": key.reporting_profile, + "scope": {"media_buy_ids": list(config.media_buy_ids)}, + "coverage_requirement": "full", + "required_finality": "official", + "reconciliation_mode": "consumer_receipt", + "schedule": offering.configuration_schedule(config), + "method": writer.configuration_binding(binding).wire(), + } + + async def resolve_account(reference, context, consumer): + account = reference.get("account_id") + if ( + account not in ACCOUNTS + or reference != {"account_id": account} + or consumer != CONSUMERS[account] + ): + raise ReportingReceiptError("UNAUTHORIZED") + return account + + async def account_task(request, context, admit): + records = [] + for entry in request["accounts"]: + account = await resolve_account(entry["account"], context, context.caller_identity) + config = config_for(account) + binding = binding_for(config) + currency = ACCOUNTS[account][0] + sources[currency].bind_generation(config) + writer.grant(binding) + states = [] + for supplied in entry.get("reporting_delivery_configs", []): + await admit( + ReportingConfigurationAdmission( + offerings[currency].offering_id, + config, + binding, + configuration_wire=supplied, + ) + ) + states.append( + { + "configuration": supplied, + "state": "ready", + "destination_ref": binding.destination_ref, + "validated_at": END.isoformat(), + "activated_at": START.isoformat(), + "current_coverage": { + "status": "full", + "evaluated_at": END.isoformat(), + "media_buy_ids": list(config.media_buy_ids), + "fully_covered_media_buy_ids": list(config.media_buy_ids), + "partially_covered_media_buy_ids": [], + "unsupported_media_buy_ids": [], + "unknown_media_buy_ids": [], + "package_ids": [], + "covered_package_ids": [], + "unsupported_package_ids": [], + "unknown_package_ids": [], + "limitations": [], + }, + } + ) + records.append( + { + "account_id": account, + "brand": {"domain": account + ".advertiser.example.test"}, + "operator": "buyer.example.test", + "action": "unchanged", + "status": "active", + "billing": "operator", + "timezone": "UTC", + "reporting_delivery_configs": states, + } + ) + return {"accounts": records} + + projection = PgReportingStatusProjection( + store, + consumer_status_enabled=True, + revision_ownership=True, + escalation=producers["USD"].escalation, + ) + support = ReportingProductionSupport( + ReportingMaterializerService(store, ReportingDestinationIO(registry, writer), writer), + projection, + offerings=tuple(offerings.values()), + configuration_task=ReportingProductionConfigurationTask( + account_task, + AccountCapabilities(supported_billing=["operator"], require_operator_auth=True), + ), + resolve_account=resolve_account, + poll_seconds=0.02, + ) + # Only external source/provider configuration is restored after restart. + # No revision, receipt, snapshot or successful work is seeded here. + templates = {} + for account in ACCOUNTS: + config = config_for(account) + sources[ACCOUNTS[account][0]].bind_generation(config) + binding = binding_for(config) + writer.grant(binding) + templates[account] = wire_for(config, binding) + + async def startup(): + await pool.open(wait=True) + await store.create_schema() + async with pool.connection() as connection: + initial = await ( + await connection.execute("SELECT count(*) FROM reporting_configurations") + ).fetchone() + await support.start() + (root / "ready.json").write_text( + json.dumps( + { + "templates": templates, + "initial_configurations": initial[0], + "pool_size": 1, + "source_observed_at": ( + None if args.live_source_observation else source_observed_at.isoformat() + ), + "live_source_observation": args.live_source_observation, + "python": __import__("sys").version, + "adcp_file": adcp.__file__, + "pydantic": importlib.metadata.version("pydantic"), + "mcp": importlib.metadata.version("mcp"), + } + ) + ) + + async def shutdown(): + await support.aclose() + await pool.close() + (root / "stopped.json").write_text( + json.dumps( + { + "stopped": True, + "source_requests": { + currency: len(source.requests) for currency, source in sources.items() + }, + "destination_sessions_closed": all( + p.opens == p.closes for p in writer.readers.values() + ), + } + ) + ) + + tokens = { + account + "-test-token": Principal(caller_identity=consumer, tenant_id="shared-tenant") + for account, consumer in CONSUMERS.items() + } + serve( + support.handler, + name="late-account-production", + transport="both", + host="127.0.0.1", + port=args.port, + auth=BearerTokenAuth(validate_token=tokens.get), + context_factory=auth_context_factory, + allowed_hosts=["127.0.0.1", "localhost"], + public_url=f"http://127.0.0.1:{args.port}", + on_startup=[startup], + on_shutdown=[shutdown], + stateless_http=True, + asgi_middleware=[(WireCapture, {"path": str(root / "wire.jsonl")})], + ) + + +if __name__ == "__main__": + main() diff --git a/tests/conformance/reporting/_late_account_support.py b/tests/conformance/reporting/_late_account_support.py new file mode 100644 index 000000000..0fd2dfa24 --- /dev/null +++ b/tests/conformance/reporting/_late_account_support.py @@ -0,0 +1,103 @@ +"""Independent currency contracts and persistent adopter I/O for public progress tests.""" + +import base64 +import hashlib +import json +from dataclasses import replace + +import rfc8785 + +from adcp.reporting.inline_source import InlineFetchResult +from adcp.reporting.materializer import ReportingRevisionVerifier, reference_verifier +from adcp.reporting.materializer.contracts import failure + +from ._production_support import Source, SQLiteDestination + +ACCOUNTS = {"usd": ("USD", 503), "eur": ("EUR", 3)} + + +def rows_for(account): + currency, count = ACCOUNTS[account] + return [ + { + "row_id": f"{number:06d}", + "impressions": number % 3, + "spend": "1.25", + "currency": currency, + "details": {"active": True, "values": [1, None, "é", "e\u0301"]}, + } + for number in range(count) + ] + + +class AccountSource(Source): + async def fetch(self, request): + account = str(request.identity.account_id) + self.requests.append(request) + return InlineFetchResult( + rows_for(account), data_through=request.period.end, currency=ACCOUNTS[account][0] + ) + + +class CurrencyDestination(SQLiteDestination): + """The same SQLite destination, with one independently pinned session per currency.""" + + def __init__(self, path, keys): + super().__init__(path, keys[0]) + self.readers = {key: SQLiteDestination(path, key) for key in keys} + + def resolve(self, request, *, phase, context): + provider = self.readers.get(request.verification_key) + if provider is None: + raise failure("AUTHORIZATION_DENIED") + return provider.resolve(request, phase=phase, context=context) + + +def verifier_for(currency, capability): + base = reference_verifier(capability) + if currency == "USD": + return base + assert currency == "EUR" + definition = json.loads(base.definition_bytes) + definition["report_definition_id"] = "reference-report-eur-v1" + for metric in definition["metrics"]: + if metric.get("unit") == "USD": + metric["unit"] = "EUR" + schema = json.loads(base.schema_bytes) + schema["properties"]["currency"]["const"] = "EUR" + schema["properties"]["spend"]["x-adcp-control-total"]["unit"] = "EUR" + + def encode(value): + return (json.dumps(value, ensure_ascii=False, indent=2) + "\n").encode() + + schema_bytes = encode(schema) + schema_hash = hashlib.sha256(schema_bytes).hexdigest() + contract = json.loads(base.canonicalization_bytes) + contract["schema_sha256"] = schema_hash + for vector in contract["golden_vectors"].values(): + for row in vector["input_rows"]: + row["currency"] = "EUR" + canonical = rfc8785.dumps(sorted(vector["input_rows"], key=lambda row: row["row_id"])) + vector["canonical_utf8_base64"] = base64.b64encode(canonical).decode() + vector["sha256"] = hashlib.sha256(canonical).hexdigest() + definition_bytes, contract_bytes = encode(definition), encode(contract) + key = replace( + base.key, + report_definition_id=definition["report_definition_id"], + definition=replace( + base.key.definition, + report_definition_uri="https://contracts.example.test/reference-eur-definition.json", + report_definition_sha256=hashlib.sha256(definition_bytes).hexdigest(), + schema_uri="https://contracts.example.test/reference-eur-schema.json", + schema_sha256=schema_hash, + monetary_metric_units=(("spend", "EUR"),), + monetary_control_total_units=(("spend", "EUR"),), + ), + canonicalization=replace( + base.key.canonicalization, + canonicalization_id="reference-eur-jcs-rows-v1", + canonicalization_uri="https://contracts.example.test/reference-eur-canonicalization.json", + canonicalization_sha256=hashlib.sha256(contract_bytes).hexdigest(), + ), + ) + return ReportingRevisionVerifier(key, definition_bytes, schema_bytes, contract_bytes) diff --git a/tests/conformance/reporting/_production_packaging.py b/tests/conformance/reporting/_production_packaging.py index fc163e339..db9c0a2d5 100644 --- a/tests/conformance/reporting/_production_packaging.py +++ b/tests/conformance/reporting/_production_packaging.py @@ -50,6 +50,11 @@ def production_modules(): for name in ( "_version.py", "server/mcp_tools.py", + "server/a2a_server.py", + "decisioning/dispatch.py", + "signing/verifier.py", + "signing/webhook_verifier.py", + "signing/webhook_signer.py", "reporting/feed/request.py", "reporting/feed/errors.py", "reporting/ledger/status_server.py", @@ -86,6 +91,8 @@ def production_modules(): "types/v32.py", "types/versioned.py", "types/generated_poc/core/reporting_delivery_capabilities.py", + "types/generated_poc/core/reporting_delivery_config.py", + "types/generated_poc/media_buy/get_media_buy_delivery_request.py", "types/generated_poc/bundled/protocol/get_adcp_capabilities_response.py", ) ] @@ -282,8 +289,17 @@ def installed_production(root, python, wheel, source, *, label, driver_absent): for name in ( "tests/conformance/reporting/test_reporting_tier_projection.py", "tests/conformance/reporting/test_reporting_schedule_schema.py", + "tests/conformance/reporting/test_reporting_materializer_progress.py", + "tests/conformance/reporting/test_reporting_publication_time.py", "tests/test_reporting_revision_ownership.py", "tests/test_reporting_capability_models.py", + "tests/test_reporting_scope_models.py", + "tests/test_reporting_exact_request_models.py", + "tests/test_reporting_feed_numeric_parameters.py", + "tests/conformance/signing/test_webhook_rc4_vectors.py", + "tests/conformance/signing/test_webhook_signature_http.py", + "tests/conformance/signing/test_webhook_signature_emission.py", + "tests/conformance/signing/test_revocation_checker_boundary.py", "tests/test_reporting_production_public.py", "tests/test_schema_datetime_formats.py", "tests/test_rc6_adoption.py", diff --git a/tests/conformance/reporting/_scope_onboarding_server.py b/tests/conformance/reporting/_scope_onboarding_server.py new file mode 100644 index 000000000..961e193a5 --- /dev/null +++ b/tests/conformance/reporting/_scope_onboarding_server.py @@ -0,0 +1,293 @@ +"""Separate HTTP process using the real production composition and rc.6 MCP mount.""" + +import asyncio +import copy +import importlib.metadata +import json +import socket +import sys +from contextlib import asynccontextmanager +from dataclasses import replace +from decimal import Decimal +from pathlib import Path + +import uvicorn + +import adcp +from adcp.reporting.ledger.store import LedgerConflictError +from adcp.reporting.production.configuration import ReportingConfigurationAdmission +from adcp.server.auth import BearerTokenAuth, Principal, auth_context_factory +from adcp.server.serve import _build_mcp_and_a2a_app + +from ._feed_support import MountedFeed, feed_harness, feed_request, mixed_case +from ._production_support import production_harness +from .test_reporting_production_configuration import state_for, wire_configuration + + +@asynccontextmanager +async def scope_fixture(backend, root): + audit = {"calls": [], "admissions": []} + selected = {} + + def save(): + temporary = root / "audit.next" + temporary.write_text(json.dumps(audit)) + temporary.replace(root / "audit.json") + + async def account_task(request, context, admit): + h = selected["h"] + audit["calls"].append(copy.deepcopy(request)) + save() + accounts = [] + for entry in request["accounts"]: + who = await h.production.handler._authorize(entry, context) + config, binding = selected[who.account_id] + wire = entry["reporting_delivery_configs"][0] + if set(wire["scope"]) == {"all_media_buys"}: + # The accepted production fixture supports explicit full scopes. + # Reaching this branch proves MCP input acceptance, not dynamic + # all-buy production support or admission of a broader scope. + raise LedgerConflictError("UNSUPPORTED_FEATURE", "explicit scope required") + await admit( + ReportingConfigurationAdmission( + h.production.offerings[0].offering_id, + config, + binding, + configuration_wire=wire, + ) + ) + selected["accepted"][who.account_id] = copy.deepcopy(wire) + audit["admissions"].append( + { + "account_id": who.account_id, + "consumer_id": who.consumer_id, + "scope": wire["scope"], + } + ) + state = state_for(h, copy.deepcopy(wire)) + accounts.append( + { + "account_id": who.account_id, + "brand": {"domain": "advertiser.example.test"}, + "operator": "buyer.example.test", + "action": "unchanged", + "status": "active", + "billing": "operator", + "timezone": "UTC", + "reporting_delivery_configs": [state], + } + ) + save() + return {"accounts": accounts} + + async with production_harness( + backend, root / "destination.sqlite", count=0, account_handler=account_task + ) as h: + selected.update(h=h, accepted={}) + requests = {} + tokens = {} + for account, caller in (("acct_a", "urn:buyer:alpha"), ("acct_b", "urn:buyer:beta")): + config = replace( + h.item.config, + account_id=account, + delivery_config_id="shared-config", + media_buy_ids=("shared-media-buy",), + ) + binding = replace( + h.item.binding, generation_key=config.generation_key, consumer_id=caller + ) + h.item.writer.grant(binding) + h.production.offerings[0].producer._source.bind_generation(config) + h.authorized_bindings.add((account, caller)) + selected[account] = config, binding + wire = wire_configuration(h) + wire.update( + delivery_config_id="shared-config", scope={"media_buy_ids": ["shared-media-buy"]} + ) + requests[account] = { + "adcp_version": "3.2-rc.6", + "idempotency_key": "public-scope-" + account, + "accounts": [ + {"account": {"account_id": account}, "reporting_delivery_configs": [wire]} + ], + } + tokens[account] = Principal(caller_identity=caller, tenant_id="shared-tenant") + # Coverage in the returned states is the requested denominator too. + h.item.config = selected["acct_a"][0] + yield h, h.production.handler, tokens, {"requests": requests}, audit, save + + +@asynccontextmanager +async def exact_fixture(backend, root, count): + # This fixture runs the owned production worker; it neither seeds a + # successful revision nor calls the materializer manually. The source + # clock is fixed at the first period's end; the configuration stays active + # so autonomous materialization is eligible. + async with production_harness( + backend, + root / "destination.sqlite", + count=count, + source_publication=True, + reconciled=True, + poll_seconds=0.02, + ) as h: + await h.production.activate(account_id=h.item.config.account_id) + for _ in range(600): + revisions = await h.store.list_revisions( + account_id=h.item.config.account_id, + reporting_obligation_id=h.item.obligation.reporting_obligation_id, + ) + if len(revisions) == 1 and h.item.writer.writes == 1: + outcomes = await h.item.outcomes() + if outcomes: + break + await asyncio.sleep(0.05) + else: + raise AssertionError( + { + "reason": "public production worker did not materialize within 30 seconds", + "revisions": len(revisions), + "writes": h.item.writer.writes, + "source_requests": len(h.production.offerings[0].producer._source.requests), + } + ) + revision = revisions[0] + assert revision.finality == "official" and revision.row_count == count + audit = {"http": []} + + def save(): + temporary = root / "audit.next" + temporary.write_text(json.dumps(audit)) + temporary.replace(root / "audit.json") + + yield h, h.production.handler, { + "acct_a": Principal( + caller_identity=h.item.binding.consumer_id, tenant_id="shared-tenant" + ), + "acct_b": Principal(caller_identity="urn:buyer:other", tenant_id="shared-tenant"), + }, { + "request": { + "account": {"account_id": h.item.config.account_id}, + "reporting_revision_id": revision.reporting_revision_id, + "pagination": {"max_results": 100}, + }, + "revision": {"row_count": count, "content_sha256": revision.revision_content_sha256}, + "source_requests": len(h.production.offerings[0].producer._source.requests), + "destination_writes": h.item.writer.writes, + }, audit, save + + +@asynccontextmanager +async def feed_fixture(backend, root, notifications): + async with feed_harness(backend, notifications=notifications) as h: + case, _, _ = await mixed_case(h) + mounted = MountedFeed(h, version="3.2-rc.6") + mounted.authorize(case, token="acct_a") + audit = {"http": []} + + def save(): + temporary = root / "audit.next" + temporary.write_text(json.dumps(audit)) + temporary.replace(root / "audit.json") + + yield h, mounted.handler, mounted.tokens, { + "request": feed_request(case, adcp_version="3.2-rc.6"), + }, audit, save + + +async def run(backend, root, socket_fd, scenario, count, notifications): + fixture = ( + scope_fixture(backend, root) + if scenario == "scope" + else ( + exact_fixture(backend, root, count) + if scenario == "exact" + else feed_fixture(backend, root, notifications) + ) + ) + async with fixture as (h, handler, tokens, ready, audit, save): + sock = socket.socket(fileno=socket_fd) + port = sock.getsockname()[1] + app = _build_mcp_and_a2a_app( + handler, + name="public-reporting-wire", + port=port, + host="127.0.0.1", + instructions=None, + test_controller=None, + context_factory=auth_context_factory, + auth=BearerTokenAuth(validate_token=tokens.get), + allowed_hosts=["127.0.0.1", "localhost"], + public_url=f"http://127.0.0.1:{port}", + stateless_http=True, + ) + + async def observed(scope, receive, send): + if scope["type"] != "http" or scope["method"] != "POST": + return await app(scope, receive, send) + before = await h.image() + chunks = [] + + async def capture(): + message = await receive() + if message["type"] == "http.request": + chunks.append(message.get("body", b"")) + return message + + await app(scope, capture, send) + try: + envelope = json.loads(b"".join(chunks), parse_float=Decimal) + except ValueError: + envelope = {} + method = envelope.get("method") + if method in {"tools/call", "message/send", "SendMessage"}: + # Request-local comparison includes snapshots and continuation + # state. It records no authentication headers or body values. + record = { + "method": method, + "store_unchanged": before == await h.image(), + } + if scenario == "feed": + params = envelope["params"] + if method == "tools/call": + params = params["arguments"] + else: + params = params["message"]["parts"][0]["data"]["parameters"] + number = params.get("ext", {}).get("vendor", {}).get("n") + # Only the fixture's numeric control is observed, before + # server conversion. This distinguishes client rounding. + record["received_number"] = {"type": type(number).__name__, "text": str(number)} + audit.setdefault("http", []).append(record) + save() + + server = uvicorn.Server(uvicorn.Config(observed, log_level="warning", lifespan="on")) + running = asyncio.create_task(server.serve(sockets=[sock])) + try: + while not server.started: + if running.done(): + await running + raise RuntimeError("HTTP server stopped before startup") + await asyncio.sleep(0.01) + save() + (root / "ready.json").write_text( + json.dumps( + { + **ready, + "python": list(sys.version_info[:3]), + "adcp_file": adcp.__file__, + "pydantic": importlib.metadata.version("pydantic"), + "mcp": importlib.metadata.version("mcp"), + } + ) + ) + await running + finally: + server.should_exit = True + await running + + +def main(): + backend, root, descriptor, scenario, count, notifications = sys.argv[1:] + asyncio.run( + run(backend, Path(root), int(descriptor), scenario, int(count), notifications == "true") + ) diff --git a/tests/conformance/reporting/test_reporting_materializer_progress.py b/tests/conformance/reporting/test_reporting_materializer_progress.py new file mode 100644 index 000000000..39c82e7da --- /dev/null +++ b/tests/conformance/reporting/test_reporting_materializer_progress.py @@ -0,0 +1,324 @@ +"""Account progress while peers stay due, including bounded busy-account scans.""" + +import asyncio +import json +import os +import secrets +from contextlib import asynccontextmanager + +import pytest + +from adcp.reporting.materializer import ( + PgReportingMaterializerStore, + ReportingMaterializerLease, + ReportingWriterError, +) + +from ._durable_materializer_support import durable_case + + +@asynccontextmanager +async def progress_pool(*, autocommit=False): + """Each scenario owns a schema and a size-one pool on the real database.""" + url = os.environ.get("ADCP_PG_TEST_URL") + if not url: + pytest.skip("ADCP_PG_TEST_URL not set — requires real PostgreSQL") + psycopg = pytest.importorskip("psycopg") + psycopg_pool = pytest.importorskip("psycopg_pool") + schema = "adcp_materializer_progress_" + secrets.token_hex(6) + async with await psycopg.AsyncConnection.connect(url, autocommit=True) as admin: + await admin.execute( + psycopg.sql.SQL("CREATE SCHEMA {}").format(psycopg.sql.Identifier(schema)) + ) + try: + async with psycopg_pool.AsyncConnectionPool( + url, + kwargs={ + "options": f"-csearch_path={schema} -cstatement_timeout=15000", + "autocommit": autocommit, + }, + min_size=1, + max_size=1, + open=False, + ) as pool: + await pool.wait(timeout=10) + yield pool, schema + finally: + await admin.execute( + psycopg.sql.SQL("DROP SCHEMA {} CASCADE").format(psycopg.sql.Identifier(schema)) + ) + + +async def wake(pool, account): + # This is the installed function invoked by real producer/binding triggers. + # The store-level regression enrolls due accounts, never successful work. + async with pool.connection() as connection: + await connection.execute("SELECT reporting_materializer_wake(%s)", (account,)) + + +async def served(pool): + async with pool.connection() as connection: + return dict( + await ( + await connection.execute( + "SELECT account_id,served_at::text FROM reporting_materializer_accounts" + " ORDER BY served_at,account_id" + ) + ).fetchall() + ) + + +@pytest.mark.parametrize( + "first,late,keep_waking_first,restart_at_enrollment,expected_late", + [ + ("account-a", "account-z", True, False, 15), + ("account-z", "account-a", True, False, 15), + ("account-a", "account-z", False, False, 30), + ("account-a", "account-z", True, True, 15), + ], + ids=["continuous", "reverse-order", "producer-stops-control", "fresh-store-control"], +) +async def test_late_due_account_is_served_while_the_first_stays_continuously_due( + first, late, keep_waking_first, restart_at_enrollment, expected_late +): + async with progress_pool() as (pool, _): + store = PgReportingMaterializerStore(pool=pool) + await store.create_schema() + await wake(pool, first) + trace = [] + for turn in range(40): + if turn == 10: + await wake(pool, late) + if restart_at_enrollment: + store = PgReportingMaterializerStore(pool=pool) + # Waking from turn zero is essential: a gap before late enrollment + # could reset the old cursor and hide the regression. + if keep_waking_first: + await wake(pool, first) + if turn >= 10: + await wake(pool, late) + before = await served(pool) + await store.claim_materialization(keys=()) + after = await served(pool) + moved = [account for account in after if before.get(account) != after[account]] + assert len(moved) <= 1 + trace.append({"turn": turn, "served": moved}) + late_turns = [row["turn"] for row in trace if late in row["served"]] + print( + json.dumps( + { + "late_account_progress": { + "first": first, + "late": late, + "continuous_first": keep_waking_first, + "restart_at_enrollment": restart_at_enrollment, + "trace": trace, + "late_turns": late_turns, + "pool_size": 1, + } + } + ), + flush=True, + ) + assert late_turns and late_turns[0] <= 11, "late account was excluded from due sampling" + assert len(late_turns) == expected_late + + +def observe_samples(monkeypatch): + psycopg = pytest.importorskip("psycopg") + original = psycopg.AsyncConnection.execute + samples = [] + + async def execute(connection, query, *args, **kwargs): + result = await original(connection, query, *args, **kwargs) + if isinstance(query, str) and query.startswith( + "SELECT account_id,served_at::text FROM reporting_materializer_accounts WHERE due_at" + ): + assert query.endswith("LIMIT 16") + samples.append(result.rowcount) + return result + + monkeypatch.setattr(psycopg.AsyncConnection, "execute", execute) + return samples + + +async def bounded_claim(store, samples, *, keys=()): + before = len(samples) + result = await asyncio.wait_for(store.claim_materialization(keys=keys), 10) + sizes = samples[before:] + assert 1 <= len(sizes) <= 2 and 0 <= sum(sizes) <= 16, sizes + return result + + +@pytest.mark.parametrize("notifications", [False, True]) +async def test_more_than_two_busy_pages_progress_then_recover_after_unlock( + notifications, monkeypatch +): + async with progress_pool(autocommit=True) as (pool, schema): + from psycopg import AsyncConnection + + store = PgReportingMaterializerStore(pool=pool, notifications=notifications) + await store.create_schema() + busy = [f"busy-{number:02}" for number in range(33)] + available = "zz-available" + for account in [*busy, available]: + await wake(pool, account) + samples = observe_samples(monkeypatch) + async with ( + await AsyncConnection.connect( + os.environ["ADCP_PG_TEST_URL"], + options=f"-csearch_path={schema} -cstatement_timeout=15000", + autocommit=True, + ) as holder, + holder.transaction(), + ): + for account in busy: + await store._lock_account(holder, account) + before = await served(pool) + for _ in range(2): + correction_condition_1 = (await bounded_claim(store, samples)).state == "idle" + assert correction_condition_1 + correction_condition_2 = await served(pool) == before + assert correction_condition_2 + assert samples == [16, 16] + await bounded_claim(store, samples) + after = await served(pool) + assert after[available] != before[available] + assert {a: after[a] for a in busy} == {a: before[a] for a in busy} + assert samples[-1] == 2 + # A successful account turn restarts at the oldest durable rank; + # subsequent busy turns must still walk through all three pages. + for expected in (16, 16, 1): + await bounded_claim(store, samples) + assert samples[-1] == expected + await bounded_claim(store, samples) + assert samples[-2:] == [0, 16] + recovered = set() + for _ in range(len(busy) + 3): + before = await served(pool) + await bounded_claim(store, samples) + after = await served(pool) + recovered.update(a for a in busy if after[a] != before[a]) + if recovered == set(busy): + break + assert recovered == set(busy) + correction_condition_3 = all( + value != "-infinity" for value in (await served(pool)).values() + ) + assert correction_condition_3 + + +@pytest.mark.parametrize("notifications", [False, True]) +async def test_commit_failure_rolls_back_rank_and_reservation_then_retries(notifications): + async with progress_pool(autocommit=True) as (pool, _): + store = PgReportingMaterializerStore(pool=pool, notifications=notifications) + await store.create_schema() + case = await durable_case(store) + before = await served(pool) + async with pool.connection() as connection: + # A deferred database error fails COMMIT after all reservation and + # scheduling statements executed. No SDK operation is replaced. + await connection.execute( + "CREATE FUNCTION progress_fail_commit() RETURNS trigger LANGUAGE plpgsql AS $$" + " BEGIN RAISE EXCEPTION 'private-progress-commit-fault'; END $$" + ) + await connection.execute( + "CREATE CONSTRAINT TRIGGER progress_fail_commit" + " AFTER UPDATE ON reporting_materializer_accounts" + " DEFERRABLE INITIALLY DEFERRED FOR EACH ROW" + " EXECUTE FUNCTION progress_fail_commit()" + ) + try: + with pytest.raises(ReportingWriterError) as error: + await store.claim_materialization(keys=case.keys) + assert error.value.failure.code == "RESOURCE_UNAVAILABLE" + assert "private-progress" not in str(error.value) + correction_condition_4 = await served(pool) == before + assert correction_condition_4 + async with pool.connection() as connection: + correction_condition_5 = await ( + await connection.execute("SELECT count(*) FROM reporting_materializer_work") + ).fetchone() == (0,) + assert correction_condition_5 + finally: + async with pool.connection() as connection: + await connection.execute( + "DROP TRIGGER progress_fail_commit ON reporting_materializer_accounts" + ) + await connection.execute("DROP FUNCTION progress_fail_commit()") + lease = await case.claim() + assert isinstance(lease, ReportingMaterializerLease) + assert lease.attempt.attempt == 1 + correction_condition_6 = await store.renew_materialization(lease, lease_seconds=30) + assert correction_condition_6 + correction_condition_7 = await served(pool) != before + assert correction_condition_7 + + +async def test_two_workers_skip_uncommitted_peer_and_reserve_distinct_accounts(monkeypatch): + async with progress_pool(autocommit=True) as (pool, schema): + from psycopg import AsyncConnection + from psycopg_pool import AsyncConnectionPool + + store = PgReportingMaterializerStore(pool=pool) + await store.create_schema() + first = await durable_case(store, account="account-a") + second = await durable_case(store, account="account-z") + async with AsyncConnectionPool( + os.environ["ADCP_PG_TEST_URL"], + kwargs={"options": f"-csearch_path={schema}", "autocommit": True}, + min_size=1, + max_size=1, + open=False, + ) as peer_pool: + await peer_pool.wait(timeout=10) + peer = PgReportingMaterializerStore(pool=peer_pool) + holding, release = asyncio.Event(), asyncio.Event() + original = AsyncConnection.execute + winner = None + + async def execute(connection, query, *args, **kwargs): + result = await original(connection, query, *args, **kwargs) + if ( + asyncio.current_task() is winner + and isinstance(query, str) + and query.startswith("UPDATE reporting_materializer_accounts SET served_at=") + ): + # Only pause after the real update, under the real locks. + holding.set() + await asyncio.wait_for(release.wait(), 10) + return result + + monkeypatch.setattr(AsyncConnection, "execute", execute) + winner = asyncio.create_task(store.claim_materialization(keys=first.keys)) + try: + await asyncio.wait_for(holding.wait(), 10) + other = await asyncio.wait_for(peer.claim_materialization(keys=second.keys), 10) + assert isinstance(other, ReportingMaterializerLease) + assert other.scope.principal.account_id == "account-z" + release.set() + selected = await asyncio.wait_for(winner, 10) + finally: + release.set() + if not winner.done(): + winner.cancel() + await asyncio.gather(winner, return_exceptions=True) + assert isinstance(selected, ReportingMaterializerLease) + assert selected.scope.principal.account_id == "account-a" + assert selected.request.external_id != other.request.external_id + await store.authorize_materialization(selected) + await peer.authorize_materialization(other) + correction_condition_8 = await store.renew_materialization(selected, lease_seconds=30) + assert correction_condition_8 + correction_condition_9 = await peer.renew_materialization(other, lease_seconds=30) + assert correction_condition_9 + for current in (store, peer): + correction_condition_10 = not isinstance( + await current.claim_materialization(keys=first.keys), ReportingMaterializerLease + ) + assert correction_condition_10 + async with pool.connection() as connection: + correction_condition_11 = await ( + await connection.execute("SELECT count(*) FROM reporting_materializer_work") + ).fetchone() == (2,) + assert correction_condition_11 diff --git a/tests/conformance/reporting/test_reporting_production_feed_numbers.py b/tests/conformance/reporting/test_reporting_production_feed_numbers.py new file mode 100644 index 000000000..6b720d34c --- /dev/null +++ b/tests/conformance/reporting/test_reporting_production_feed_numbers.py @@ -0,0 +1,282 @@ +"""Raw lexemes and typed reporting clients over separate-process HTTP mounts.""" + +import json +from copy import deepcopy +from decimal import Decimal + +import httpx +import pytest + +from adcp.types import GetReportingStatusRequest + +from ._receipt_transport import error_code +from .test_reporting_production_scope import ( + audited, + onboarding_server, + public_client, +) + + +@pytest.fixture(autouse=True) +def _a2a_compat_send_and_aggregate(): + # Real SDK network streams, without the repository's unit-mock shim. + pass + + +def assert_invalid_checkpoint(result): + assert not result.success and result.data is None, result + if result.adcp_error is not None: + assert result.adcp_error["code"] == "INVALID_CHECKPOINT", result + else: + # The MCP adapter preserves the closed public task error as text. + assert result.error.endswith( + "restart the reporting walk; this position is unavailable for this scope" + ), result + + +async def raw_call(client, route, request, *, lexeme=None): + headers = {"Authorization": "Bearer acct_a", "Content-Type": "application/json"} + if route == "mcp": + headers["Accept"] = "application/json, text/event-stream" + initial = await client.post( + "/mcp/", + headers=headers, + json={ + "jsonrpc": "2.0", + "id": 0, + "method": "initialize", + "params": { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": {"name": "exact-reporting-numbers", "version": "1"}, + }, + }, + ) + assert initial.status_code == 200 + envelope = { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "get_reporting_status", "arguments": request}, + } + path = "/mcp/" + else: + v1 = route == "a2a-1.0" + headers["A2A-Version"] = "1.0" if v1 else "0.3" + part = {"data": {"skill": "get_reporting_status", "parameters": request}} + if not v1: + part["kind"] = "data" + envelope = { + "jsonrpc": "2.0", + "id": "1", + "method": "SendMessage" if v1 else "message/send", + "params": { + "message": { + "messageId": "exact-number-message", + "role": "ROLE_USER" if v1 else "user", + "parts": [part], + } + }, + } + path = "/" + wire = json.dumps(envelope) + if lexeme is not None: + assert wire.count('"__exact_lexeme__"') == 1 + wire = wire.replace('"__exact_lexeme__"', lexeme) + response = await client.post(path, headers=headers, content=wire) + assert response.status_code in {200, 400}, response.text + payload = next( + (json.loads(line[6:]) for line in response.text.splitlines() if line.startswith("data: ")), + None, + ) + if payload is None: + payload = response.json() + result = payload.get("result", payload) + if "structuredContent" in result: + return result["structuredContent"] + if "task" in result: + result = result["task"] + for artifact in result.get("artifacts", []): + for part in artifact.get("parts", []): + if "data" in part: + return part["data"] + return result + + +@pytest.mark.parametrize("backend", ["memory", "postgres"]) +@pytest.mark.parametrize("notifications", [False, True]) +async def test_large_received_decimal_cannot_replay_a_different_filter( + backend, notifications, tmp_path +): + async with onboarding_server( + backend, tmp_path, scenario="feed", notifications=notifications + ) as (uri, ready): + async with httpx.AsyncClient(base_url=uri.removesuffix("/mcp/"), timeout=30) as client: + routes = ("mcp", "a2a-0.3", "a2a-1.0") + for index, route in enumerate(routes): + other = routes[(index + 1) % len(routes)] + for original, changed in ( + ("9007199254740993.0", "9007199254740992.0"), + ("9007199254740995.0", "9007199254740996.0"), + ("9.007199254740993e15", "9007199254740992"), + ): + raw = {**ready["request"], "ext": {"vendor": {"n": "__exact_lexeme__"}}} + first = await audited(tmp_path, raw_call(client, route, raw, lexeme=original)) + assert first["pagination"]["has_more"], first + continued = deepcopy(raw) + continued["pagination"] = { + "cursor": first["pagination"]["cursor"], + "max_results": 100, + } + # A bare exact integer and its decimal/exponent spellings + # remain the same semantic filter, across protocols. + last = await audited( + tmp_path, + raw_call(client, other, continued, lexeme=str(int(Decimal(original)))), + ) + assert last["pagination"]["has_more"] is False + assert last["ledger_snapshot_id"] == first["ledger_snapshot_id"] + assert last["changes_checkpoint"] == first["changes_checkpoint"] + for position in ( + {"pagination": continued["pagination"]}, + {"changes_after": last["changes_checkpoint"]}, + ): + rejected = await audited( + tmp_path, raw_call(client, other, {**raw, **position}, lexeme=changed) + ) + assert "pagination" not in rejected, rejected + assert error_code(rejected) == "INVALID_CHECKPOINT", rejected + after = json.loads((tmp_path / "audit.json").read_text()) + assert after["http"][-1]["store_unchanged"] + empty = await audited( + tmp_path, + raw_call( + client, + route, + { + **raw, + "changes_after": last["changes_checkpoint"], + }, + lexeme=original, + ), + ) + assert empty["pagination"] == {"total_count": 0, "has_more": False} + + for lexeme in ( + "0.100000000000000000001", + "9007199254740993.25", + "1e-400", + "1e400", + "NaN", + "Infinity", + "-Infinity", + ): + raw = { + **ready["request"], + "ext": { + "vendor": { + "n": "__exact_lexeme__", + "private": "numeric-redaction-control", + } + }, + } + rejected = await audited(tmp_path, raw_call(client, route, raw, lexeme=lexeme)) + assert "pagination" not in rejected + if "error" in rejected: + assert rejected["error"]["code"] in {-32700, -32600, -32602} + else: + assert error_code(rejected) in {"INVALID_REQUEST", "VALIDATION_ERROR"} + assert "numeric-redaction-control" not in json.dumps(rejected) + assert lexeme not in json.dumps(rejected) + assert json.loads((tmp_path / "audit.json").read_text())["http"][-1][ + "store_unchanged" + ] + + +@pytest.mark.parametrize("backend", ["memory", "postgres"]) +async def test_typed_numbers_cross_protocol_and_client_rounding_are_distinct(backend, tmp_path): + async with onboarding_server(backend, tmp_path, scenario="feed") as (uri, ready): + for version in ("0.3", "1.0"): + async with ( + public_client(uri, "acct_a") as mcp, + public_client(uri, "acct_a", "a2a-" + version) as a2a, + ): + for start, finish in ((mcp, a2a), (a2a, mcp)): + raw = { + **ready["request"], + "ext": { + "vendor": { + "n": 1, + "fraction": 4.8, + "nested": [0.5, True, False, "1", None], + } + }, + } + first = await audited( + tmp_path, + start.get_reporting_status(GetReportingStatusRequest.model_validate(raw)), + ) + assert first.success, first + initial = first.data.model_dump(mode="json", exclude_unset=True) + continued = { + **raw, + "pagination": { + "max_results": 100, + "cursor": initial["pagination"]["cursor"], + }, + } + last = await audited( + tmp_path, + finish.get_reporting_status( + GetReportingStatusRequest.model_validate(continued) + ), + ) + assert last.success, last + final = last.data.model_dump(mode="json", exclude_unset=True) + assert final["pagination"] == {"total_count": 6, "has_more": False} + assert final["ledger_snapshot_id"] == initial["ledger_snapshot_id"] + for changed in (True, "1", 0.5, None): + for position in ( + {"pagination": continued["pagination"]}, + {"changes_after": final["changes_checkpoint"]}, + ): + negative = deepcopy({**raw, **position}) + negative["ext"]["vendor"]["n"] = changed + denied = await audited( + tmp_path, + finish.get_reporting_status( + GetReportingStatusRequest.model_validate(negative) + ), + ) + assert_invalid_checkpoint(denied) + assert json.loads((tmp_path / "audit.json").read_text())["http"][-1][ + "store_unchanged" + ] + exact = {**ready["request"], "ext": {"vendor": {"n": 9007199254740993}}} + first = await audited( + tmp_path, + mcp.get_reporting_status(GetReportingStatusRequest.model_validate(exact)), + ) + assert first.success, first + assert json.loads((tmp_path / "audit.json").read_text())["http"][-1][ + "received_number" + ] == { + "type": "int", + "text": "9007199254740993", + } + for position in ( + {"pagination": {"cursor": first.data.pagination.cursor}}, + {"changes_after": first.data.changes_checkpoint}, + ): + denied = await audited( + tmp_path, + a2a.get_reporting_status( + GetReportingStatusRequest.model_validate({**exact, **position}) + ), + ) + assert_invalid_checkpoint(denied) + audit = json.loads((tmp_path / "audit.json").read_text())["http"][-1] + assert Decimal(audit["received_number"]["text"]) == 9007199254740992 + assert audit["store_unchanged"] + # Protobuf rounded before transmission. The server rejects + # the changed filter; it cannot recover the intended value. diff --git a/tests/conformance/reporting/test_reporting_production_late_accounts.py b/tests/conformance/reporting/test_reporting_production_late_accounts.py new file mode 100644 index 000000000..bc9d1df1a --- /dev/null +++ b/tests/conformance/reporting/test_reporting_production_late_accounts.py @@ -0,0 +1,436 @@ +"""Typed public enrollment, autonomous late-account delivery and durable restart.""" + +import asyncio +import hashlib +import json +import os +import signal +import socket +import sqlite3 +import subprocess +import sys +import time +from contextlib import asynccontextmanager +from pathlib import Path + +import pytest +import rfc8785 +from pydantic import TypeAdapter + +from adcp import ADCPClient, AgentConfig +from adcp.reporting import ( + ExpectedReportingPeriod, + ReportingObservation, + load_reporting_ledger, + reconcile_reporting, +) +from adcp.reporting.materializer import ReportingWriterCapability, reference_digest +from adcp.types import ( + GetAdcpCapabilitiesRequest, + GetMediaBuyDeliveryRequest, + GetReportingStatusRequest, + ReportingCanonicalContentDigest, + ReportingControlTotal, + SyncAccountsRequest, +) + +from ._late_account_support import ACCOUNTS, rows_for, verifier_for +from .test_reporting_materializer_progress import progress_pool, served +from .test_reporting_production_scope import assert_access_denied + + +@asynccontextmanager +async def running_server(root, schema, index, *, notifications, live_source_observation=False): + fixture_root = Path(__file__).resolve().parents[3] + launcher = ( + "import sys; sys.path.insert(0,sys.argv.pop(1)); " + "from tests.conformance.reporting._late_account_server import main; main()" + ) + (root / "ready.json").unlink(missing_ok=True) + (root / "stopped.json").unlink(missing_ok=True) + with socket.socket() as listener: + listener.bind(("127.0.0.1", 0)) + port = listener.getsockname()[1] + command = [ + sys.executable, + "-I", + "-c", + launcher, + str(fixture_root), + "--port", + str(port), + "--root", + str(root), + "--schema", + schema, + ] + if notifications: + command.append("--notifications") + if live_source_observation: + command.append("--live-source-observation") + with ( + (root / f"server-{index}.stdout").open("xb") as output, + (root / f"server-{index}.stderr").open("xb") as errors, + ): + child = subprocess.Popen( + command, + cwd=root, + stdout=output, + stderr=errors, + start_new_session=True, + ) + try: + for _ in range(1800): + assert child.poll() is None, (root / f"server-{index}.stderr").read_text() + if (root / "ready.json").exists(): + try: + _, writer = await asyncio.open_connection("127.0.0.1", port) + except OSError: + pass + else: + writer.close() + await writer.wait_closed() + break + await asyncio.sleep(0.05) + else: + raise AssertionError("public production startup exceeded 90 seconds") + ready = json.loads((root / "ready.json").read_text()) + (root / f"ready-{index}.json").write_text(json.dumps(ready)) + yield f"http://127.0.0.1:{port}/mcp/", ready + finally: + if child.poll() is None: + os.killpg(child.pid, signal.SIGTERM) + try: + await asyncio.to_thread(child.wait, 25) + except subprocess.TimeoutExpired: + os.killpg(child.pid, signal.SIGKILL) + await asyncio.to_thread(child.wait, 5) + cleanup = { + "pid": child.pid, + "exit": child.returncode, + "reaped": child.poll() is not None, + } + if (root / "stopped.json").exists(): + cleanup.update(json.loads((root / "stopped.json").read_text())) + (root / f"cleanup-{index}.json").write_text(json.dumps(cleanup)) + assert cleanup.get("stopped") and cleanup["destination_sessions_closed"], cleanup + + +def client_for(uri, account): + return ADCPClient( + AgentConfig( + id="late-" + account, + agent_uri=uri, + protocol="mcp", + auth_token=account + "-test-token", + auth_header="Authorization", + auth_type="bearer", + ), + adcp_version="3.2.0-rc.6", + ) + + +def data(result): + assert result.success and result.data is not None, result + return result.data.model_dump(mode="json", exclude_none=True) + + +async def delivered_and_reconciled(client, root, ready, account, *, replay=False, period=None): + currency, count = ACCOUNTS[account] + capability = ReportingWriterCapability( + "warehouse_materialization", + "fixture-sql", + "jsonl", + "canonical_digest", + "destination", + "immutable_location", + "sha256", + "conditional_create", + ) + verifier = verifier_for(currency, capability) + selectors = {"account": {"account_id": account}, "view": "periods"} + if period is not None: + selectors["period"] = period + request = GetReportingStatusRequest.model_validate(selectors) + started = time.monotonic() + ledger = None + calls = 0 + # Every readiness observation is an actual typed RPC. The scheduler and + # producer use real clocks; no worker is stepped, stopped or manually invoked. + while time.monotonic() - started < 45 and calls < 150: + ledger = await load_reporting_ledger(client, request) + calls += 1 + if any( + str(getattr(m.status, "value", m.status)) == "delivered" + for m in ledger.materializations + ): + break + else: + raise AssertionError({"account": account, "materialization_missing_after_calls": calls}) + readiness_seconds = time.monotonic() - started + if period is None: + # Catch-up production is deliberately active throughout this test. Pin + # a publicly delivered period, rather than assuming which candidate the + # materializer chooses first among that account's outstanding periods. + delivered = next( + m + for m in ledger.materializations + if str(getattr(m.status, "value", m.status)) == "delivered" + ) + obligation = next( + o + for o in ledger.obligations + if o.reporting_obligation_id == delivered.reporting_obligation_id + ) + period = {name: getattr(obligation.period, name).isoformat() for name in ("start", "end")} + selectors["period"] = period + request = GetReportingStatusRequest.model_validate(selectors) + ledger = await load_reporting_ledger(client, request) + assert len(ledger.obligations) == len(ledger.revisions) == len(ledger.materializations) == 1 + revision = ledger.revisions[0] + materialization = ledger.materializations[0] + assert revision.account_id == ledger.obligations[0].account_id == account + assert revision.row_count == count + params = { + "account": {"account_id": account}, + "reporting_revision_id": revision.reporting_revision_id, + "pagination": {"max_results": 100}, + } + exact, binding, pages = [], None, 0 + while True: + page = data( + await client.get_media_buy_delivery(GetMediaBuyDeliveryRequest.model_validate(params)) + ) + if binding is None: + binding = page["reporting_revision_binding"] + else: + assert binding == page["reporting_revision_binding"] + exact.extend(page["reporting_rows"]) + pages += 1 + if not page["pagination"]["has_more"]: + break + params["pagination"]["cursor"] = page["pagination"]["cursor"] + assert pages < 10 + assert exact == rows_for(account) + assert pages == (6 if count == 503 else 1) + digest_input = {k: binding[k] for k in ("reporting_revision_id", "row_count", "control_totals")} + digest_input["reporting_rows"] = exact + digest = hashlib.sha256(rfc8785.dumps(digest_input)).hexdigest() + assert digest == binding["content_sha256"] == revision.revision_content_sha256 + + async def inspect(context): + with sqlite3.connect(root / "destination.sqlite") as connection: + records = [ + json.loads(row[0]) for row in connection.execute("SELECT content FROM artifacts") + ] + matched = [ + record + for record in records + if record["revision"] == context.revision.reporting_revision_id + and record["resource"]["location"] == context.materialization.resource.location + ] + assert len(matched) == 1 + rows = [json.loads(row) for row in matched[0]["rows"]] + assert rows == exact + _, totals = verifier.canonicalize(rows) + return ReportingObservation( + len(rows), + [TypeAdapter(ReportingControlTotal).validate_python(t.to_wire()) for t in totals], + ReportingCanonicalContentDigest.model_validate( + reference_digest(verifier, rows).to_wire() + ), + ) + + template = ready["templates"][account] + expected = [ + ExpectedReportingPeriod( + "shared-config", + 1, + template["report_definition_id"], + "billing", + template["reporting_profile"], + ("shared-media-buy",), + period["start"], + period["end"], + ) + ] + outcome = await reconcile_reporting( + client, request, inspect, expected_periods=expected, inspection_retry_backoff_seconds=0 + ) + assert outcome.definitive, [(o.definitive, o.reasons) for o in outcome.obligations] + assert len(outcome.submitted_receipts) == (0 if replay else 1) + repeat = await reconcile_reporting( + client, request, inspect, expected_periods=expected, inspection_retry_backoff_seconds=0 + ) + assert repeat.definitive and not repeat.submitted_receipts + other = "eur" if account == "usd" else "usd" + assert_access_denied( + await client.get_reporting_status( + GetReportingStatusRequest.model_validate( + { + "account": {"account_id": other}, + "view": "periods", + } + ) + ) + ) + assert_access_denied( + await client.get_media_buy_delivery( + GetMediaBuyDeliveryRequest.model_validate( + { + "account": {"account_id": other}, + "reporting_revision_id": revision.reporting_revision_id, + } + ) + ) + ) + return { + "account": account, + "currency": currency, + "rows": count, + "pages": pages, + "revision": revision.reporting_revision_id, + "digest": digest, + "receipt_ids": [receipt.reporting_receipt_id for receipt in repeat.ledger.receipts], + "materialization_id": materialization.reporting_materialization_id, + "period": period, + "readiness_calls": calls, + "readiness_seconds": round(readiness_seconds, 3), + "completed_seconds": round(time.monotonic() - started, 3), + } + + +async def ongoing_first_turns(pool, account): + """Observe real committed turns after first-account RPCs have finished. + + A one-period fixture can settle or encounter a busy-lock wrap during those + RPCs, accidentally passing with the old cursor. Catch-up publications keep + pending work due. These plain MVCC reads neither take account locks nor + wake, advance or execute either SDK worker. + """ + observed = [] + deadline = time.monotonic() + 30 + previous = (await served(pool))[account] + while time.monotonic() < deadline: + async with pool.connection() as connection: + row = await ( + await connection.execute( + "SELECT served_at::text,due_at<=clock_timestamp()," + " (SELECT count(*) FROM reporting_materializer_candidates c" + " WHERE c.account_id=a.account_id AND c.due_at<=clock_timestamp())" + " FROM reporting_materializer_accounts a WHERE account_id=%s", + (account,), + ) + ).fetchone() + if row[0] != previous: + observed.append({"served_at": row[0], "due": row[1], "due_candidates": row[2]}) + previous = row[0] + if len(observed) >= 3 and all( + r["due"] and r["due_candidates"] >= 2 for r in observed[-3:] + ): + return observed[-3:] + await asyncio.sleep(0.02) + raise AssertionError({"continuous_first_work_not_established": observed}) + + +@pytest.mark.parametrize("first", ["usd", "eur"], ids=["late-sorts-first", "late-sorts-last"]) +@pytest.mark.parametrize("notifications", [False, True]) +async def test_late_account_progresses_via_typed_public_support_and_survives_restart( + first, notifications, tmp_path +): + order = (first, "eur" if first == "usd" else "usd") + async with progress_pool(autocommit=True) as (pool, schema): + results = [] + continuing_work = None + for index in range(2): + async with running_server(tmp_path, schema, index, notifications=notifications) as ( + uri, + ready, + ): + assert ready["pool_size"] == 1 + assert ready["initial_configurations"] == (0 if index == 0 else 2) + current = {} + for account in order: + if index == 0 and account != first: + continuing_work = await ongoing_first_turns(pool, first) + async with client_for(uri, account) as client: + caps = data( + await client.get_adcp_capabilities(GetAdcpCapabilitiesRequest()) + ) + assert caps["media_buy"]["reporting_delivery"]["managed_delivery"] + if index == 0: + # The first remains active throughout late admission; + # no worker restart, due-gap workaround or manual turn. + first_before = (await served(pool)).get(first) + onboarding = SyncAccountsRequest.model_validate( + { + "idempotency_key": "late-account-" + account, + "accounts": [ + { + "account": {"account_id": account}, + "reporting_delivery_configs": [ + ready["templates"][account] + ], + } + ], + } + ) + admitted = data(await client.sync_accounts(onboarding)) + assert admitted["accounts"][0]["account_id"] == account + assert ( + admitted["accounts"][0]["reporting_delivery_configs"][0]["state"] + == "ready" + ) + current[account] = await delivered_and_reconciled( + client, + tmp_path, + ready, + account, + replay=bool(index), + period=results[0][account]["period"] if index else None, + ) + if index == 0 and account != first: + correction_condition_1 = (await served(pool))[first] != first_before + assert correction_condition_1 + results.append(current) + async with pool.connection() as connection: + correction_condition_2 = await ( + await connection.execute( + "SELECT count(*) FROM pg_stat_activity WHERE application_name=%s", (schema,) + ) + ).fetchone() == (0,) + assert correction_condition_2 + for account in order: + for field in ("revision", "digest", "receipt_ids", "materialization_id"): + assert results[0][account][field] == results[1][account][field] + assert results[0]["usd"]["revision"] != results[0]["eur"]["revision"] + assert results[0]["usd"]["materialization_id"] != results[0]["eur"]["materialization_id"] + wire = (tmp_path / "wire.jsonl").read_text() + assert "-test-token" not in wire + for line in wire.splitlines(): + request = json.loads(json.loads(line)["request_utf8"]) + if request.get("method") != "tools/call": + continue + task, arguments = request["params"]["name"], request["params"]["arguments"] + if task == "sync_accounts": + assert arguments["accounts"][0]["reporting_delivery_configs"][0]["scope"] == { + "media_buy_ids": ["shared-media-buy"], + } + if task == "get_media_buy_delivery": + assert "include_package_daily_breakdown" not in arguments + assert "include_window_breakdown" not in arguments + print( + json.dumps( + { + "public_late_account_progress": { + "order": order, + "notifications": notifications, + "runs": results, + "typed_onboarding_and_exact_reads": True, + "autonomous_worker": True, + "observed_due_first_turns_before_late_admission": continuing_work, + } + } + ), + flush=True, + ) diff --git a/tests/conformance/reporting/test_reporting_production_publication_time.py b/tests/conformance/reporting/test_reporting_production_publication_time.py new file mode 100644 index 000000000..026ff948e --- /dev/null +++ b/tests/conformance/reporting/test_reporting_production_publication_time.py @@ -0,0 +1,122 @@ +"""Actual MCP/PG publication after real source observation, including restart.""" + +import json +from datetime import datetime + +import pytest + +from adcp.reporting import load_reporting_ledger +from adcp.types import GetReportingStatusRequest, SyncAccountsRequest + +from .test_reporting_materializer_progress import progress_pool +from .test_reporting_production_late_accounts import ( + client_for, + data, + delivered_and_reconciled, + running_server, +) + + +@pytest.mark.parametrize("account", ["usd", "eur"]) +async def test_real_source_observation_before_publication_reconciles_and_replays(account, tmp_path): + async with progress_pool(autocommit=True) as (pool, schema): + runs = [] + for index in range(2): + async with running_server( + tmp_path, + schema, + index, + notifications=False, + live_source_observation=True, + ) as (uri, ready): + assert ready["pool_size"] == 1 and ready["live_source_observation"] + assert ready["initial_configurations"] == (0 if index == 0 else 1) + async with client_for(uri, account) as client: + if index == 0: + admitted = data( + await client.sync_accounts( + SyncAccountsRequest.model_validate( + { + "idempotency_key": "publication-time-" + account, + "accounts": [ + { + "account": {"account_id": account}, + "reporting_delivery_configs": [ + ready["templates"][account] + ], + } + ], + } + ) + ) + ) + assert ( + admitted["accounts"][0]["reporting_delivery_configs"][0]["state"] + == "ready" + ) + result = await delivered_and_reconciled( + client, + tmp_path, + ready, + account, + replay=bool(index), + period=runs[0]["period"] if index else None, + ) + ledger = await load_reporting_ledger( + client, + GetReportingStatusRequest.model_validate( + { + "account": {"account_id": account}, + "view": "periods", + "period": result["period"], + } + ), + ) + assert len(ledger.revisions) == 1 + revision = ledger.revisions[0] + observations = [ + json.loads(line) + for line in (tmp_path / "source-observations.jsonl") + .read_text() + .splitlines() + ] + matches = [ + item + for item in observations + if item["account"] == account and item["period"] == result["period"] + ] + assert len(matches) == 1 + source = matches[0] + # These values come from the actual source's public request + # and result, and the revision comes from a typed HTTP read. + assert datetime.fromisoformat(source["source_read_cutoff_at"]) < ( + revision.observed_at + ) + assert revision.observed_at == datetime.fromisoformat(source["observed_at"]) + assert revision.finalized_at == datetime.fromisoformat(source["finalized_at"]) + assert revision.data_through == datetime.fromisoformat(source["data_through"]) + assert datetime.fromisoformat(source["acquired_at"]) <= revision.created_at + assert revision.finalized_at <= revision.created_at + result["source"] = source + result["revision_evidence"] = revision.model_dump( + mode="json", exclude_none=True + ) + runs.append(result) + async with pool.connection() as connection: + correction_condition_1 = await ( + await connection.execute( + "SELECT count(*) FROM pg_stat_activity WHERE application_name=%s", (schema,) + ) + ).fetchone() == (0,) + assert correction_condition_1 + for field in ( + "revision", + "digest", + "receipt_ids", + "materialization_id", + "source", + "revision_evidence", + ): + assert runs[0][field] == runs[1][field] + assert "-test-token" not in (tmp_path / "wire.jsonl").read_text() + print(json.dumps({"public_real_clock_publication": {"account": account, "runs": runs}})) diff --git a/tests/conformance/reporting/test_reporting_production_scope.py b/tests/conformance/reporting/test_reporting_production_scope.py new file mode 100644 index 000000000..0cdcab325 --- /dev/null +++ b/tests/conformance/reporting/test_reporting_production_scope.py @@ -0,0 +1,306 @@ +"""Public typed onboarding over real MCP HTTP, including installed floor wheels.""" + +import asyncio +import json +import os +import signal +import socket +import subprocess +import sys +from contextlib import asynccontextmanager +from copy import deepcopy +from pathlib import Path + +import pytest +import rfc8785 +from pydantic import ValidationError + +from adcp import ADCPClient, AgentConfig +from adcp.types import GetMediaBuyDeliveryRequest, GetReportingStatusRequest, SyncAccountsRequest +from adcp.validation.schema_loader import get_named_validator + + +@asynccontextmanager +async def onboarding_server(backend, root, *, scenario="scope", count=0, notifications=False): + if backend == "postgres": + if not os.environ.get("ADCP_PG_TEST_URL"): + pytest.skip("requires real PostgreSQL") + pytest.importorskip("psycopg") + fixture_root = Path(__file__).resolve().parents[3] + # Only test fixtures are put on sys.path. SDK imports still come from the + # current interpreter's installation (a wheel in the installed gate). + launcher = ( + "import sys; sys.path.insert(0, sys.argv.pop(1)); " + "from tests.conformance.reporting._scope_onboarding_server import main; main()" + ) + with socket.socket() as listener: + listener.bind(("127.0.0.1", 0)) + listener.listen() + port = listener.getsockname()[1] + with (root / "server.stdout").open("xb") as out, (root / "server.stderr").open("xb") as err: + child = subprocess.Popen( + [ + sys.executable, + "-I", + "-c", + launcher, + str(fixture_root), + backend, + str(root), + str(listener.fileno()), + scenario, + str(count), + str(notifications).lower(), + ], + cwd=root, + stdout=out, + stderr=err, + pass_fds=(listener.fileno(),), + start_new_session=True, + ) + try: + for _ in range(1800): + if (root / "ready.json").exists(): + break + assert child.poll() is None, (root / "server.stderr").read_text() + await asyncio.sleep(0.05) + else: + raise AssertionError("MCP startup exceeded 90 seconds") + yield f"http://127.0.0.1:{port}/mcp/", json.loads((root / "ready.json").read_text()) + finally: + if child.poll() is None: + os.killpg(child.pid, signal.SIGTERM) + try: + await asyncio.to_thread(child.wait, 15) + except subprocess.TimeoutExpired: + os.killpg(child.pid, signal.SIGKILL) + await asyncio.to_thread(child.wait) + + +def public_client(uri, account, route="mcp"): + return ADCPClient( + AgentConfig( + id="scope-" + account, + agent_uri=uri if route == "mcp" else uri.removesuffix("/mcp/"), + protocol="mcp" if route == "mcp" else "a2a", + auth_token=account, + auth_header="Authorization", + auth_type="bearer", + ), + adcp_version="3.2.0-rc.6", + force_a2a_version=route.removeprefix("a2a-") if route != "mcp" else None, + ) + + +@pytest.fixture(autouse=True) +def _a2a_compat_send_and_aggregate(): + # Keep the real a2a-sdk stream; the repository's unit mock shim is not + # appropriate for these separate-process HTTP integrations. + pass + + +async def last_http_audit(root, previous): + for _ in range(100): + value = json.loads((root / "audit.json").read_text()) + if len(value.get("http", [])) > previous: + return value + await asyncio.sleep(0.01) + raise AssertionError("HTTP request did not retain its before/after store comparison") + + +async def audited(root, call): + previous = len(json.loads((root / "audit.json").read_text()).get("http", [])) + result = await call + await last_http_audit(root, previous) + return result + + +def assert_access_denied(result): + # MCP may retain an ADCPTaskError as redacted text, whereas A2A returns + # the structured classification. In either case no account data is sent. + assert not result.success and result.data is None, result + if result.adcp_error is not None: + assert result.adcp_error["code"] == "UNAUTHORIZED", result + else: + assert result.error.endswith( + "the reporting account or authenticated consumer is unavailable" + ), result.error + + +@pytest.mark.parametrize("backend", ["memory", "postgres"]) +async def test_typed_onboarding_reaches_real_mcp_production_admission(backend, tmp_path): + async with onboarding_server(backend, tmp_path) as (uri, ready): + raw = ready["requests"]["acct_a"] + before = deepcopy(raw) + validator = get_named_validator("account/sync-accounts-request.json") + assert validator is not None and not list(validator.iter_errors(raw)) + request = SyncAccountsRequest.model_validate(raw) + async with public_client(uri, "acct_a") as client: + result = await client.sync_accounts(request) + assert result.success, result + assert raw == before + audit = json.loads((tmp_path / "audit.json").read_text()) + assert audit["admissions"] == [ + { + "account_id": "acct_a", + "consumer_id": "urn:buyer:alpha", + "scope": {"media_buy_ids": ["shared-media-buy"]}, + } + ] + + +@pytest.mark.parametrize("backend", ["memory", "postgres"]) +async def test_typed_scope_modes_accounts_and_invalid_requests_over_public_http(backend, tmp_path): + async with onboarding_server(backend, tmp_path) as (uri, ready): + for route in ("mcp", "a2a-0.3", "a2a-1.0"): + for account in ("acct_a", "acct_b"): + request = SyncAccountsRequest.model_validate(ready["requests"][account]) + async with public_client(uri, account, route) as client: + for _ in range(2): + response = await audited(tmp_path, client.sync_accounts(request)) + assert response.success, response + wire = response.data.model_dump(mode="json", exclude_none=True) + assert [a["account_id"] for a in wire["accounts"]] == [account] + assert wire["accounts"][0]["reporting_delivery_configs"][0][ + "configuration" + ]["scope"] == {"media_buy_ids": ["shared-media-buy"]} + own = await audited( + tmp_path, + client.get_reporting_status( + GetReportingStatusRequest.model_validate( + { + "account": {"account_id": account}, + "view": "summary", + } + ) + ), + ) + assert own.success, own + other = "acct_b" if account == "acct_a" else "acct_a" + denied = await audited( + tmp_path, + client.get_reporting_status( + GetReportingStatusRequest.model_validate( + { + "account": {"account_id": other}, + "view": "summary", + } + ) + ), + ) + assert_access_denied(denied) + denied = await audited( + tmp_path, + client.sync_accounts( + SyncAccountsRequest.model_validate(ready["requests"][other]) + ), + ) + assert_access_denied(denied) + + for scope in ({}, {"all_media_buys": True}): + raw = deepcopy(ready["requests"][account]) + raw["accounts"][0]["reporting_delivery_configs"][0]["scope"] = scope + before = json.loads((tmp_path / "audit.json").read_text()) + response = await audited( + tmp_path, client.sync_accounts(SyncAccountsRequest.model_validate(raw)) + ) + assert not response.success and response.data is None, response + assert "VALIDATION_ERROR" not in str(response.error), response + after = await last_http_audit(tmp_path, len(before["http"])) + assert len(after["calls"]) == len(before["calls"]) + 1 + assert after["calls"][-1]["accounts"][0]["reporting_delivery_configs"][0][ + "scope" + ] == {"all_media_buys": True} + assert after["admissions"] == before["admissions"] + assert after["http"][-1]["store_unchanged"] + + bad = deepcopy(ready["requests"][account]) + bad["accounts"][0]["reporting_delivery_configs"][0]["scope"][ + "all_media_buys" + ] = True + with pytest.raises(ValidationError): + SyncAccountsRequest.model_validate(bad) + before = json.loads((tmp_path / "audit.json").read_text()) + # The raw negative control reaches the actual server + # validator; successful onboarding above is always typed. + rejected = await audited(tmp_path, client.adapter.sync_accounts(bad)) + assert ( + not rejected.success and rejected.adcp_error["code"] == "VALIDATION_ERROR" + ), rejected + after = await last_http_audit(tmp_path, len(before["http"])) + assert after["calls"] == before["calls"] + assert after["admissions"] == before["admissions"] + assert after["http"][-1]["store_unchanged"] + + +@pytest.mark.parametrize("backend", ["memory", "postgres"]) +@pytest.mark.parametrize("count", [0, 503]) +async def test_typed_exact_revision_pages_after_actual_publication(backend, count, tmp_path): + import hashlib + + from ._materializer_support import reference_rows + + async with onboarding_server(backend, tmp_path, scenario="exact", count=count) as (uri, ready): + assert ready["source_requests"] == ready["destination_writes"] == 1 + for route in ("mcp", "a2a-0.3", "a2a-1.0"): + raw = deepcopy(ready["request"]) + pages, rows, seen = [], [], set() + async with public_client(uri, "acct_a", route) as client: + for _ in range(7): + result = await client.get_media_buy_delivery( + GetMediaBuyDeliveryRequest.model_validate(raw) + ) + assert result.success, result + page = result.data.model_dump(mode="json", exclude_none=True) + binding = page["reporting_revision_binding"] + assert binding["reporting_revision_id"] == raw["reporting_revision_id"] + assert binding["row_count"] == count + assert binding["content_sha256"] == ready["revision"]["content_sha256"] + assert page["reporting_revision"]["finality"] == "official" + pages.append(page) + rows.extend(page["reporting_rows"]) + if not page["pagination"]["has_more"]: + break + cursor = page["pagination"]["cursor"] + assert cursor not in seen + seen.add(cursor) + raw["pagination"]["cursor"] = cursor + else: + pytest.fail("exact revision traversal failed to terminate") + assert len(pages) == (6 if count else 1) + assert rows == reference_rows(count) + # Protobuf responses spell ordinary integral JSON values as + # doubles. JCS canonicalizes these fixture values identically; + # this comparison does not relax financial ingress validation + # or claim recovery of a value rounded before transmission. + recomputed = hashlib.sha256( + rfc8785.dumps( + { + "reporting_revision_id": raw["reporting_revision_id"], + "row_count": count, + "control_totals": binding["control_totals"], + "reporting_rows": rows, + } + ) + ).hexdigest() + assert recomputed == binding["content_sha256"] + replay = await client.get_media_buy_delivery( + GetMediaBuyDeliveryRequest.model_validate(raw) + ) + assert ( + replay.success + and replay.data.model_dump(mode="json", exclude_none=True) == pages[-1] + ) + for field in ("include_package_daily_breakdown", "include_window_breakdown"): + invalid = {**ready["request"], field: False} + with pytest.raises(ValidationError): + GetMediaBuyDeliveryRequest.model_validate(invalid) + rejected = await client.adapter.get_media_buy_delivery(invalid) + assert ( + not rejected.success and rejected.adcp_error["code"] == "VALIDATION_ERROR" + ), rejected + async with public_client(uri, "acct_b", route) as client: + denied = await client.get_media_buy_delivery( + GetMediaBuyDeliveryRequest.model_validate(raw) + ) + assert_access_denied(denied) diff --git a/tests/conformance/reporting/test_reporting_publication_time.py b/tests/conformance/reporting/test_reporting_publication_time.py new file mode 100644 index 000000000..a10347a37 --- /dev/null +++ b/tests/conformance/reporting/test_reporting_publication_time.py @@ -0,0 +1,411 @@ +"""Publication clocks are distinct from dispatch and immutable source evidence.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone + +import pytest +from pydantic import ValidationError + +from adcp.reporting import ( + ExpectedReportingPeriod, + ReportingLedger, + evaluate_reporting_ledger, +) +from adcp.reporting.conformance import validate_reporting_source_execution +from adcp.reporting.ledger import ( + InMemoryReportingLedgerStore, + LedgerConflictError, + PgReportingLedgerStore, + ProducerOfferings, + ReportingConfiguration, + ReportingProducer, + ReportingScheduleSpec, + ReportingStatusCaller, + ReportingStatusHandler, +) +from adcp.reporting.materializer import ReportingWriterCapability, reference_verifier +from adcp.reporting.source import SourceBatchManifestV1, parse_verified_source_batch_manifest_v1 +from adcp.types import GetReportingStatusResponse +from adcp.validation.schema_loader import get_named_validator + +from ._generation_support import END, START, isolated_reporting_pool +from ._production_support import Source + +TURN = END + timedelta(hours=1) +PUBLISHED = TURN + timedelta(seconds=2) +ROWS = [{"row_id": "1", "impressions": 5, "spend": "1.25", "currency": "USD"}] + + +@dataclass +class Clock: + now: datetime = TURN + + def __call__(self): + return self.now + + +class RecordedSource(Source): + async def execute(self, request, *, cancel, heartbeat=None): + await asyncio.sleep(0) # The source can complete after the dispatch instant. + result = await super().execute(request, cancel=cancel, heartbeat=heartbeat) + self.executions.append((request, result)) + return result + + +class DelayedReader: + def __init__(self, reader, complete): + self.reader, self.complete = reader, complete + self.calls = 0 + + async def read(self, **kwargs): + result = await self.reader.read(**kwargs) + await asyncio.sleep(0) + self.complete() + self.calls += 1 + return result + + +@pytest.fixture(params=["memory", "postgres"]) +async def store(request): + if request.param == "memory": + yield InMemoryReportingLedgerStore(clock=lambda: datetime.now(timezone.utc)) + else: + async with isolated_reporting_pool() as pool: + ledger = PgReportingLedgerStore(pool=pool) + await ledger.create_schema() + yield ledger + + +async def setup( + store, path, *, observed=TURN, completion=PUBLISHED, real=False, finality="official" +): + verifier = reference_verifier( + ReportingWriterCapability( + "warehouse_materialization", + "fixture-sql", + "jsonl", + "canonical_digest", + "destination", + "immutable_location", + "sha256", + "conditional_create", + ) + ) + key = verifier.key + clock = Clock() + source = RecordedSource( + key, + path, + ROWS, + official=finality == "official", + product_ids=(key.report_definition_id,), + clock=(lambda: datetime.now(timezone.utc)) if real else (lambda: observed), + ) + source.executions = [] + config = ReportingConfiguration( + "publication-clock", + 1, + "account-clock", + key.report_definition_id, + key.reporting_profile, + "analytics", + ReportingScheduleSpec("PT1H", "PT1H", period_anchor=START), + finality, + activated_at=START, + deactivated_at=END, + media_buy_ids=("mb-clock",), + definition=key.definition, + ) + await store.put_configuration(config) + + def complete_read(): + if not real: + clock.now = completion + + reader = DelayedReader(source.reader, complete_read) + + def producer(): + return ReportingProducer( + source=source, + store=store, + object_reader=reader, + offerings=ProducerOfferings( + official_offering_id=source.source_id if finality == "official" else None, + snapshot_offering_id=source.source_id if finality == "snapshot" else None, + publication_namespace=source.capabilities.offerings[0].publication_namespace, + source_scope=source.capabilities.source_scope, + ), + revision_verifier=verifier, + max_periods_per_turn=1, + **({} if real else {"clock": clock}), + ) + + return config, source, reader, clock, producer + + +async def public_outcome(store, config): + payload = await ReportingStatusHandler(store).handle( + { + "adcp_version": "3.2-rc.6", + "account": {"account_id": config.account_id}, + "view": "periods", + "period": {"start": START.isoformat(), "end": END.isoformat()}, + }, + caller=ReportingStatusCaller(account_id=config.account_id, consumer_id="clock-buyer"), + ) + response = GetReportingStatusResponse.model_validate(payload) + validator = get_named_validator("core/reporting-revision.json", version="3.2.0-rc.6") + assert validator is not None + for revision in payload["revisions"]: + validator.validate(revision) + ledger = ReportingLedger( + ledger_snapshot_id=response.ledger_snapshot_id, + ledger_as_of=response.ledger_as_of, + account_id=response.account_id, + scope=response.scope, + obligations=response.periods, + revisions=response.revisions, + materializations=response.materializations, + receipts=response.receipts, + ) + expected = [ + ExpectedReportingPeriod( + config.delivery_config_id, + 1, + config.report_definition_id, + config.feed_purpose, + config.reporting_profile, + config.media_buy_ids, + START.isoformat(), + END.isoformat(), + ) + ] + return response, evaluate_reporting_ledger(ledger, expected_periods=expected) + + +@pytest.mark.parametrize("observed", [END, TURN, TURN + timedelta(seconds=1)]) +async def test_creation_follows_acquisition_and_staged_read(store, tmp_path, observed): + config, source, reader, clock, factory = await setup( + store, tmp_path / "source", observed=observed + ) + turn = await factory().run_worker() + assert len(turn.revisions_committed) == len(source.executions) == reader.calls == 1 + request, result = source.executions[0] + manifest = await validate_reporting_source_execution( + capabilities=source.capabilities, + request=request, + result=result, + object_reader=source.reader, + clock=clock, + ) + response, outcome = await public_outcome(store, config) + revision = response.revisions[0] + assert outcome.definitive, [o.reasons for o in outcome.obligations] + assert request.period.source_read_cutoff_at == TURN + assert revision.created_at == PUBLISHED + assert revision.observed_at == revision.finalized_at == manifest.observed_at == observed + assert revision.data_through == END + + +async def test_real_clock_observation_after_dispatch_is_definitive(store, tmp_path): + config, source, reader, _, factory = await setup(store, tmp_path / "source", real=True) + turn = await factory().run_worker() + assert len(turn.revisions_committed) == reader.calls == 1 + request, result = source.executions[0] + manifest = await validate_reporting_source_execution( + capabilities=source.capabilities, + request=request, + result=result, + object_reader=source.reader, + ) + response, outcome = await public_outcome(store, config) + revision = response.revisions[0] + assert outcome.definitive, [o.reasons for o in outcome.obligations] + assert request.period.source_read_cutoff_at < manifest.observed_at <= revision.created_at + assert revision.observed_at == revision.finalized_at == manifest.observed_at + + +@pytest.mark.parametrize( + "observed,completion", + [(TURN + timedelta(seconds=30), PUBLISHED), (END, TURN - timedelta(seconds=1))], + ids=["source-clock-in-future", "trusted-clock-regresses-after-dispatch"], +) +async def test_invalid_publication_clock_stops_before_immutable_write( + store, tmp_path, observed, completion, monkeypatch +): + config, source, _, _, factory = await setup( + store, + tmp_path / "source", + observed=observed, + completion=completion, + ) + writes = [] + original = store.commit_revision + + async def commit(revision, rows): + writes.append(revision) + return await original(revision, rows) + + monkeypatch.setattr(store, "commit_revision", commit) + with pytest.raises(LedgerConflictError) as raised: + await factory().run_worker() + assert raised.value.code == "PUBLICATION_TIME_INVALID" + assert not writes + request, result = source.executions[0] + manifest = parse_verified_source_batch_manifest_v1( + result.response.manifest, result.manifest_bytes + ) + assert manifest.observed_at == observed # Original source evidence was not backdated. + correction_condition_1 = ( + await store.list_revisions( + account_id=config.account_id, + reporting_obligation_id=request.identity.reporting_obligation_id, + ) + == () + ) + assert correction_condition_1 + + +@pytest.mark.parametrize("finality", ["official", "snapshot"]) +async def test_same_publication_replay_preserves_committed_time_and_source_evidence( + store, tmp_path, finality +): + config, source, reader, clock, factory = await setup( + store, tmp_path / "source", finality=finality + ) + producer = factory() + turn = await producer.run_worker() + assert len(turn.revisions_committed) == 1 + request, result = source.executions[0] + manifest = parse_verified_source_batch_manifest_v1( + result.response.manifest, result.manifest_bytes + ) + obligation = await store.get_obligation( + account_id=config.account_id, + reporting_obligation_id=request.identity.reporting_obligation_id, + ) + first = await store.get_revision( + account_id=config.account_id, reporting_revision_id=turn.revisions_committed[0] + ) + assert obligation is not None and first is not None + expected_revisions = (first,) + if finality == "snapshot": + clock.now = PUBLISHED + timedelta(minutes=1) + reader.complete = lambda: None + restated = await producer.acquire_obligation(config, obligation, restate=True) + assert restated is not None + assert restated.supersedes_reporting_revision_id == first.reporting_revision_id + expected_revisions = (first, restated) + clock.now = PUBLISHED + timedelta(minutes=10) + for caller in (producer, factory()): + replay = await caller.commit_revision_from_manifest( + obligation, + manifest, + rows=ROWS, + finality=finality, + now=clock.now, + ) + assert replay == first + correction_condition_2 = ( + await store.list_revisions( + account_id=config.account_id, reporting_obligation_id=obligation.reporting_obligation_id + ) + == expected_revisions + ) + assert correction_condition_2 + changed = [dict(ROWS[0], row_id="changed")] + with pytest.raises(LedgerConflictError) as conflict: + await factory().commit_revision_from_manifest( + obligation, + manifest, + rows=changed, + finality=finality, + now=clock.now, + ) + assert conflict.value.code == "REVISION_IMMUTABLE" + + +async def test_explicit_dispatch_time_and_source_temporal_negatives(store, tmp_path): + config, source, _, _, factory = await setup(store, tmp_path / "source") + producer = factory() + obligations = await producer.close_elapsed_periods(config, now=TURN) + assert len(obligations) == 1 + first = await producer.acquire_obligation(config, obligations[0], now=TURN) + request, result = source.executions[0] + manifest = parse_verified_source_batch_manifest_v1( + result.response.manifest, result.manifest_bytes + ) + assert first is not None and request.period.source_read_cutoff_at == TURN + assert first.created_at == PUBLISHED + for updates in ( + {"observed_at": END - timedelta(seconds=1)}, + { + "finality_evidence": { + **manifest.finality_evidence.model_dump(), + "observed_at": manifest.acquired_at + timedelta(seconds=1), + } + }, + ): + with pytest.raises(ValidationError): + SourceBatchManifestV1.model_validate({**manifest.model_dump(), **updates}) + + +@pytest.mark.parametrize("future_acquisition", [False, True]) +async def test_direct_commit_uses_explicit_publication_time_before_any_write( + store, tmp_path, monkeypatch, future_acquisition +): + config, source, reader, clock, factory = await setup(store, tmp_path / "source") + producer = factory() + obligations = await producer.close_elapsed_periods(config, now=TURN) + + async def unavailable(**kwargs): + raise OSError("fixture staged read interrupted") + + # Retain a real source publication after an interrupted read, before any + # revision exists. The public commit primitive can then retry that manifest. + with monkeypatch.context() as patch: + patch.setattr(reader, "read", unavailable) + with pytest.raises(OSError, match="fixture staged read interrupted"): + await producer.acquire_obligation(config, obligations[0], now=TURN) + _, result = source.executions[0] + manifest = parse_verified_source_batch_manifest_v1( + result.response.manifest, result.manifest_bytes + ) + clock.now = PUBLISHED + timedelta(minutes=10) + if future_acquisition: + # Source-contract-valid evidence may still be in the future relative to + # the trusted publication clock. It must fail at the producer boundary. + manifest = SourceBatchManifestV1.model_validate( + {**manifest.model_dump(), "acquired_at": PUBLISHED + timedelta(seconds=1)} + ) + writes = [] + commit = store.commit_revision + + async def record_write(*args, **kwargs): + writes.append(args) + return await commit(*args, **kwargs) + + monkeypatch.setattr(store, "commit_revision", record_write) + with pytest.raises(LedgerConflictError) as raised: + await producer.commit_revision_from_manifest( + obligations[0], manifest, rows=ROWS, finality="official", now=PUBLISHED + ) + assert raised.value.code == "PUBLICATION_TIME_INVALID" + assert not writes + correction_condition_3 = ( + await store.list_revisions( + account_id=config.account_id, + reporting_obligation_id=obligations[0].reporting_obligation_id, + ) + == () + ) + assert correction_condition_3 + else: + committed = await producer.commit_revision_from_manifest( + obligations[0], manifest, rows=ROWS, finality="official", now=PUBLISHED + ) + assert committed.created_at == PUBLISHED + assert committed.observed_at == committed.finalized_at == TURN diff --git a/tests/conformance/signing/_webhook_http_server.py b/tests/conformance/signing/_webhook_http_server.py new file mode 100644 index 000000000..035e5eb40 --- /dev/null +++ b/tests/conformance/signing/_webhook_http_server.py @@ -0,0 +1,94 @@ +"""A real loopback HTTP receiver for installed webhook boundary regressions.""" + +import asyncio +import hashlib +import json +import socket +import sys +from contextlib import asynccontextmanager +from importlib.resources import files +from pathlib import Path + +import uvicorn +from starlette.applications import Starlette +from starlette.responses import JSONResponse +from starlette.routing import Route + +from adcp.server.idempotency import MemoryBackend, WebhookDedupStore +from adcp.webhooks import WebhookReceiver, WebhookReceiverConfig, WebhookVerifyOptions + + +async def run(root, socket_fd): + key_rows = json.loads( + files("adcp") + .joinpath("_compliance/3.2.0-rc.6/test-vectors/webhook-signing/keys.json") + .read_text() + )["keys"] + keys = {row["kid"]: {k: v for k, v in row.items() if not k.startswith("_")} for row in key_rows} + observations = {"key_lookups": 0, "processed": 0} + + def resolve(kid): + observations["key_lookups"] += 1 + return keys.get(kid) + + receiver = WebhookReceiver( + config=WebhookReceiverConfig( + verify_options=WebhookVerifyOptions(jwks_resolver=resolve), + dedup=WebhookDedupStore(MemoryBackend(), ttl_seconds=86400), + receiver_scope="loopback-test-receiver", + publisher_scope_for=lambda _signer: "loopback-test-publisher", + ) + ) + + async def receive(request): + body = await request.body() + outcome = await receiver.receive( + method=request.method, + url=str(request.url), + headers=dict(request.headers), + body=body, + ) + if outcome.http_status is None: + observations["processed"] += 1 + outcome = await receiver.acknowledge(outcome) + with (root / "received.jsonl").open("a") as received: + received.write( + json.dumps( + { + "body_sha256": hashlib.sha256(body).hexdigest(), + "signature": request.headers.get("signature"), + "http_status": outcome.http_status or 200, + } + ) + + "\n" + ) + return JSONResponse( + {**observations, "rejected": outcome.rejected, "reason": outcome.rejection_reason}, + status_code=outcome.http_status or 200, + headers=dict(outcome.response_headers), + ) + + @asynccontextmanager + async def lifespan(app): + yield + (root / "stopped").write_text("stopped\n") + + app = Starlette(routes=[Route("/webhook", receive, methods=["POST"])], lifespan=lifespan) + sock = socket.socket(fileno=socket_fd) + server = uvicorn.Server(uvicorn.Config(app, log_level="warning")) + running = asyncio.create_task(server.serve(sockets=[sock])) + try: + while not server.started: + if running.done(): + await running + raise RuntimeError("webhook HTTP server stopped before startup") + await asyncio.sleep(0.01) + (root / "ready").write_text("ready\n") + await running + finally: + server.should_exit = True + await running + + +def main(): + asyncio.run(run(Path(sys.argv[1]), int(sys.argv[2]))) diff --git a/tests/conformance/signing/test_revocation_checker_boundary.py b/tests/conformance/signing/test_revocation_checker_boundary.py new file mode 100644 index 000000000..7ad1dcef3 --- /dev/null +++ b/tests/conformance/signing/test_revocation_checker_boundary.py @@ -0,0 +1,428 @@ +"""Public verifier classification and live signed-list receiver composition. + +The live test uses real loopback HTTP for both the issuer and callback. Its +injected wall/monotonic clocks exercise the documented cache grace interval; +callback signature verification and revocation-list JWS verification are +observed separately and still execute their real cryptography. +""" + +from __future__ import annotations + +import asyncio +import json +import threading +import traceback +from contextlib import contextmanager +from datetime import datetime, timezone +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +import pytest + +from adcp.server.idempotency import MemoryBackend, WebhookDedupStore +from adcp.signing import ( + CachingRevocationChecker, + InMemoryReplayStore, + SignatureVerificationError, + StaticJwksResolver, + VerifierCapability, + VerifyOptions, + sign_request, + verify_request_signature, +) +from adcp.signing.revocation_fetcher import ( + FetchResult, + RevocationListFetchError, + RevocationListFreshnessError, + RevocationListParseError, + RevocationListSignatureError, +) +from adcp.webhooks import ( + WebhookReceiver, + WebhookReceiverConfig, + WebhookVerifyOptions, + create_mcp_webhook_payload, + sign_webhook, + to_wire_dict, + verify_webhook_signature, +) + +from .test_revocation_e2e import _make_operator_key, _make_signer_key, _sign_revocation_list + +NOW = 1_776_520_800 +URL = "https://buyer.example.test/webhook" +ROUTES = ("3.0", "3.1", "3.2", "webhook") +MAPPED_ERRORS = ( + RevocationListFetchError, + RevocationListParseError, + RevocationListSignatureError, + RevocationListFreshnessError, +) + + +def _signed_call(route, checker, resolver, replay, private, kid): + body = b'{"test":"revocation classification"}' + common = dict( + method="POST", + url=URL, + headers={"Content-Type": "application/json"}, + body=body, + private_key=private, + key_id=kid, + alg="ed25519", + created=NOW, + nonce="revocation-boundary-nonce", + ) + if route == "webhook": + signed = sign_webhook(**common) + options = WebhookVerifyOptions( + jwks_resolver=resolver, + revocation_checker=checker, + replay_store=replay, + clock=lambda: NOW, + ) + verifier = verify_webhook_signature + else: + signed = sign_request(**common, signing_profile_version=route, cover_content_digest=True) + options = VerifyOptions( + now=NOW, + capability=VerifierCapability(covers_content_digest="required"), + operation="test", + jwks_resolver=resolver, + revocation_checker=checker, + replay_store=replay, + signing_profile_version=route, + ) + verifier = verify_request_signature + + def run(): + return verifier( + method="POST", + url=URL, + headers={"Content-Type": "application/json", **signed.as_dict()}, + body=body, + options=options, + ) + + return run + + +def _observe_crypto_and_replay(monkeypatch, replay, events): + from adcp.signing import verifier + + original = verifier.verify_signature + + def verify(**kwargs): + events.append("callback_crypto") + return original(**kwargs) + + monkeypatch.setattr(verifier, "verify_signature", verify) + for name in ("seen", "remember", "claim", "at_capacity"): + method = getattr(replay, name) + + def observed(*args, _method=method, _name=name, **kwargs): + events.append("replay_" + _name) + return _method(*args, **kwargs) + + monkeypatch.setattr(replay, name, observed) + + +@pytest.mark.parametrize("route", ROUTES) +@pytest.mark.parametrize("error_type", MAPPED_ERRORS) +def test_checker_failure_is_safe_step_nine_and_does_not_claim_nonce(route, error_type, monkeypatch): + private, jwk = _make_signer_key() + events = [] + failing = True + replay = InMemoryReplayStore() + _observe_crypto_and_replay(monkeypatch, replay, events) + + def resolve(kid): + events.append("jwks") + assert kid == jwk["kid"] + return jwk + + def check(kid): + events.append("checker") + assert kid == jwk["kid"] + if failing: + raise error_type( + "private fixture detail: https://issuer.example.test/untrusted-payload" + ) + return False + + run = _signed_call(route, check, resolve, replay, private, jwk["kid"]) + with pytest.raises(SignatureVerificationError) as raised: + run() + prefix = "webhook" if route == "webhook" else "request" + assert (raised.value.code, raised.value.step) == (prefix + "_signature_revocation_stale", 9) + assert raised.value.detail is None + assert "private fixture" not in str(raised.value) + assert "issuer.example.test" not in str(raised.value) + assert "untrusted-payload" not in str(raised.value) + rendered = "".join(traceback.format_exception(raised.value)) + assert "issuer.example.test" not in rendered and "private fixture detail" not in rendered + assert events == ["jwks", "checker"] + + # The identical signed request remains usable after refresh: stale rejection + # must not consume its nonce. A subsequent genuine replay must be rejected. + failing = False + assert run().key_id == jwk["kid"] + assert events.count("checker") == 2 + assert events.count("callback_crypto") == 1 + assert "replay_claim" in events + with pytest.raises(SignatureVerificationError) as replayed: + run() + assert replayed.value.code == prefix + "_signature_replayed" + + +@pytest.mark.parametrize("route", ROUTES) +@pytest.mark.parametrize("error_type", (RuntimeError, ValueError, asyncio.CancelledError)) +def test_unrelated_checker_errors_remain_operational(route, error_type, monkeypatch): + private, jwk = _make_signer_key() + error = error_type("adopter checker error") + events = [] + replay = InMemoryReplayStore() + _observe_crypto_and_replay(monkeypatch, replay, events) + + def check(_kid): + events.append("checker") + raise error + + run = _signed_call(route, check, lambda _kid: jwk, replay, private, jwk["kid"]) + with pytest.raises(error_type) as raised: + run() + assert raised.value is error + assert events == ["checker"] + + +@pytest.mark.parametrize("route", ROUTES) +@pytest.mark.parametrize("revoked", (False, True)) +def test_boolean_revocation_controls_keep_their_order(route, revoked, monkeypatch): + private, jwk = _make_signer_key() + events = [] + replay = InMemoryReplayStore() + _observe_crypto_and_replay(monkeypatch, replay, events) + + def check(_kid): + events.append("checker") + return revoked + + run = _signed_call(route, check, lambda _kid: jwk, replay, private, jwk["kid"]) + if revoked: + with pytest.raises(SignatureVerificationError) as raised: + run() + prefix = "webhook" if route == "webhook" else "request" + assert (raised.value.code, raised.value.step) == (prefix + "_signature_key_revoked", 9) + assert events == ["checker"] + else: + assert run().key_id == jwk["kid"] + assert events.count("checker") == events.count("callback_crypto") == 1 + assert "replay_claim" in events + + +@contextmanager +def _http_server(handler): + server = ThreadingHTTPServer(("127.0.0.1", 0), handler) + thread = threading.Thread(target=server.serve_forever) + thread.start() + try: + yield "http://127.0.0.1:" + str(server.server_port) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + assert not thread.is_alive() + + +def _reply(handler, status, body, headers=()): + handler.send_response(status) + for name, value in headers: + handler.send_header(name, value) + handler.send_header("Content-Length", str(len(body))) + handler.end_headers() + handler.wfile.write(body) + + +def _post(url, body, headers): + request = Request(url, data=body, headers=headers, method="POST") + try: + response = urlopen(request, timeout=5) # noqa: S310 - ephemeral loopback fixture + except HTTPError as error: + response = error + with response: + return response.status, dict(response.headers), json.loads(response.read()) + + +def test_live_signed_issuer_grace_stale_rejection_and_recovery(monkeypatch): + from adcp.signing import jws + + operator, operator_jwk = _make_operator_key() + private, signer_jwk = _make_signer_key() + state = {"wall": NOW, "mono": 0, "outage": False, "refreshed": False} + events = [] + fetches = [] + handled = [] + callback_bodies = [] + operational = [] + replay = InMemoryReplayStore() + _observe_crypto_and_replay(monkeypatch, replay, events) + original_jws_verify = jws.verify_signature + + def verify_list(**kwargs): + events.append("list_jws_crypto") + return original_jws_verify(**kwargs) + + monkeypatch.setattr(jws, "verify_signature", verify_list) + + class Issuer(BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def do_GET(self): # noqa: N802 + assert self.path == "/revocations" + fetches.append((state["wall"], self.headers.get("If-None-Match"))) + if state["outage"]: + _reply(self, 503, b"unavailable") + return + updated, following = (480, 660) if state["refreshed"] else (-60, 60) + payload = { + "version": 1, + "issuer": "https://issuer.example.test", + "updated": datetime.fromtimestamp(NOW + updated, timezone.utc).isoformat(), + "next_update": datetime.fromtimestamp(NOW + following, timezone.utc).isoformat(), + "revoked_kids": [], + "revoked_jtis": [], + } + body = _sign_revocation_list( + operator_key=operator, operator_kid=operator_jwk["kid"], payload=payload + ).encode() + _reply(self, 200, body, (("ETag", '"signed-list"'),)) + + with _http_server(Issuer) as issuer_url: + + def fetch(uri, *, if_none_match=None, if_modified_since=None): + headers = {} if if_none_match is None else {"If-None-Match": if_none_match} + try: + with urlopen(Request(uri, headers=headers), timeout=5) as response: # noqa: S310 + return FetchResult(response.read().decode(), response.headers.get("ETag")) + except (URLError, TimeoutError) as error: + raise RevocationListFetchError("untrusted fixture transport detail") from error + + checker = CachingRevocationChecker( + revocation_uri=issuer_url + "/revocations", + issuer="https://issuer.example.test", + jwks_resolver=StaticJwksResolver({"keys": [operator_jwk]}), + fetcher=fetch, + clock=lambda: state["mono"], + wall_clock=lambda: datetime.fromtimestamp(state["wall"], timezone.utc), + ) + + def check(kid): + events.append("checker") + return checker(kid) + + def resolve(kid): + events.append("callback_jwks") + return signer_jwk if kid == signer_jwk["kid"] else None + + receiver = WebhookReceiver( + WebhookReceiverConfig( + verify_options=WebhookVerifyOptions( + jwks_resolver=resolve, + revocation_checker=check, + replay_store=replay, + clock=lambda: NOW, + ), + dedup=WebhookDedupStore(MemoryBackend(), ttl_seconds=86400), + receiver_scope="live-revocation-test", + publisher_scope_for=lambda _sender: "fixture-publisher", + ) + ) + + class Callback(BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def do_POST(self): # noqa: N802 + body = self.rfile.read(int(self.headers["Content-Length"])) + callback_bodies.append(body) + try: + outcome = receiver.receive_and_process_sync( + method="POST", + url=callback_url + "/webhook", + headers=dict(self.headers), + body=body, + handler=lambda payload: handled.append(payload.idempotency_key), + ) + except Exception as error: # retain operational errors separately from SDK outcomes + operational.append(type(error).__name__) + _reply(self, 503, json.dumps({"operational": type(error).__name__}).encode()) + return + _reply( + self, + outcome.http_status or 200, + json.dumps({"handled": outcome.handled, "rejected": outcome.rejected}).encode(), + outcome.response_headers.items(), + ) + + with _http_server(Callback) as callback_url: + + def signed(index): + payload = create_mcp_webhook_payload( + task_id=f"live-list-{index}", + task_type="sync_reporting_status", + operation_id=f"live-list-operation-{index}", + status="completed", + idempotency_key=f"whk_revocation_list_fixture_{index:04d}", + ) + body = json.dumps(to_wire_dict(payload), ensure_ascii=False, indent=1).encode() + signed_headers = sign_webhook( + method="POST", + url=callback_url + "/webhook", + headers={"Content-Type": "application/json"}, + body=body, + private_key=private, + key_id=signer_jwk["kid"], + alg="ed25519", + created=NOW, + nonce=f"live-list-nonce-{index:04d}", + ) + return body, {"Content-Type": "application/json", **signed_headers.as_dict()} + + def deliver(sample): + response = _post(callback_url + "/webhook", *sample) + assert callback_bodies[-1] == sample[0] + return response + + assert deliver(signed(1))[0] == 200 + assert len(fetches) == 1 and len(handled) == 1 + assert events.count("list_jws_crypto") == events.count("callback_crypto") == 1 + assert deliver(signed(2))[0] == 200 # fresh cache: no second fetch + assert len(fetches) == 1 and len(handled) == 2 + state.update(wall=NOW + 100, mono=60, outage=True) + assert deliver(signed(3))[0] == 200 # outage within signed interval + 2x grace + assert len(fetches) == 2 and len(handled) == 3 + state.update(wall=NOW + 500, mono=120) + sample = signed(4) + before = len(events) + stale = deliver(sample) + assert events[before:] == ["callback_jwks", "checker"] + assert len(fetches) == 3 and len(handled) == 3 + # Continue recovery even on the old implementation, retaining the + # full causal sequence before asserting the required classification. + state.update(wall=NOW + 500, mono=240, outage=False, refreshed=True) + recovered = deliver(sample) + assert recovered[0] == 200 and recovered[2]["handled"] + assert len(fetches) == 4 and len(handled) == 4 + assert events.count("list_jws_crypto") == 2 + assert events.count("callback_crypto") == 4 + assert events.count("checker") == 5 + assert fetches[0][1] is None and all(etag == '"signed-list"' for _, etag in fetches[1:]) + assert stale[0] == 401, {"stale": stale, "operational": operational} + assert ( + stale[1]["WWW-Authenticate"] + == 'Signature error="webhook_signature_revocation_stale"' + ) + assert stale[2] == {"handled": False, "rejected": True} + assert not operational diff --git a/tests/conformance/signing/test_webhook_rc4_vectors.py b/tests/conformance/signing/test_webhook_rc4_vectors.py new file mode 100644 index 000000000..460707c77 --- /dev/null +++ b/tests/conformance/signing/test_webhook_rc4_vectors.py @@ -0,0 +1,155 @@ +"""The rc.4 webhook-v1 corpus, unchanged in the shipped current fixtures.""" + +from __future__ import annotations + +import base64 +import json +from dataclasses import replace +from datetime import datetime, timezone +from importlib.resources import files + +import pytest + +from adcp.signing import InMemoryReplayStore, SignatureVerificationError +from adcp.signing.revocation import RevocationList +from adcp.webhooks import WebhookVerifyOptions, verify_webhook_signature + +VECTORS = files("adcp").joinpath("_compliance/3.2.0-rc.6/test-vectors/webhook-signing") +KEYS = { + row["kid"]: {name: value for name, value in row.items() if not name.startswith("_")} + for row in json.loads(VECTORS.joinpath("keys.json").read_text())["keys"] +} +NAMES = sorted( + f"{kind}/{path.name}" + for kind in ("positive", "negative") + for path in VECTORS.joinpath(kind).iterdir() + if path.name.endswith(".json") +) + + +def vector_options(vector): + state = vector.get("test_harness_state", {}) + cap_for = state.get("per_keyid_cap_filled_for") + replay = InMemoryReplayStore(per_keyid_cap=1 if cap_for else 1_000_000) + if cap_for: + assert replay.remember(cap_for, "test-capacity-prefill", 3600) + for entry in state.get("replay_cache_entries", []): + assert replay.remember(entry["keyid"], entry["nonce"], 3600) + keys = {kid: KEYS[kid] for kid in vector["jwks_ref"]} + keys.update(vector.get("jwks_override", {})) + revoked = set(state.get("revoked_kids", [])) + revocation = None + if "revocation_list_stale_seconds" in state: + # The corpus permits public in-process state installation. This is + # distinct from its live stale-fetch receiver-runner composition. + stale_at = vector["reference_now"] - state["revocation_list_stale_seconds"] + revocation = RevocationList( + issuer="https://test-agent.example.test", + updated=datetime.fromtimestamp(stale_at - 1, timezone.utc).isoformat(), + next_update=datetime.fromtimestamp(stale_at, timezone.utc).isoformat(), + ) + return WebhookVerifyOptions( + jwks_resolver=keys.get, + replay_store=replay, + revocation_checker=revoked.__contains__, + revocation_list=revocation, + clock=lambda: vector["reference_now"], + ) + + +@pytest.mark.parametrize("name", NAMES) +def test_protocol_owned_webhook_vector(name): + vector = json.loads(VECTORS.joinpath(name).read_text()) + if name == "negative/019-revocation-stale.json": + assert vector["requires_contract"] == "webhook_receiver_runner" + assert vector["black_box_behavior"] == "simulate_stale_revocation_fetch" + assert vector_options(vector).revocation_list is not None + request = vector["request"] + arguments = { + "method": request["method"], + "url": request["url"], + "headers": request["headers"], + "body": request["body"].encode("utf-8"), + "options": vector_options(vector), + } + expected = vector["expected_outcome"] + if expected["success"]: + assert verify_webhook_signature(**arguments).label == "sig1" + else: + with pytest.raises(SignatureVerificationError) as raised: + verify_webhook_signature(**arguments) + assert raised.value.code == expected["error_code"] + if name.split("/")[1][:3] in {"006", "009", "015", "019", "021"}: + assert raised.value.step == expected["failed_step"] + + +@pytest.mark.parametrize( + "signature", + [ + "sig1=:A+B-CDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789AB:", + "sig1=:A/B_CDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789AB:", + ], +) +def test_mixed_alphabet_stops_before_lookup_crypto_or_replay(signature, monkeypatch): + vector = json.loads(VECTORS.joinpath("positive/001-basic-post.json").read_text()) + request = vector["request"] + options = vector_options(vector) + + def forbidden(*args, **kwargs): + pytest.fail("malformed signature reached key lookup, crypto or replay state") + + monkeypatch.setattr("adcp.signing.verifier.verify_signature", forbidden) + for method in ("seen", "remember", "claim", "at_capacity"): + monkeypatch.setattr(options.replay_store, method, forbidden) + options = replace(options, jwks_resolver=forbidden) + with pytest.raises(SignatureVerificationError) as raised: + verify_webhook_signature( + method=request["method"], + url=request["url"], + headers={**request["headers"], "Signature": signature}, + body=request["body"].encode(), + options=options, + ) + assert (raised.value.code, raised.value.step) == ("webhook_signature_header_malformed", 1) + assert str(raised.value) == "webhook Signature must not mix Base64 alphabets" + + +@pytest.mark.parametrize("encoding", ["standard_padded", "url_unpadded"]) +def test_legacy_decoder_tolerance_and_unselected_labels(encoding): + vector = json.loads(VECTORS.joinpath("positive/001-basic-post.json").read_text()) + request = vector["request"] + token = request["headers"]["Signature"].split(":")[1] + raw = base64.urlsafe_b64decode(token + "=" * (-len(token) % 4)) + token = ( + base64.b64encode(raw).decode() + if encoding == "standard_padded" + else base64.urlsafe_b64encode(raw).decode().rstrip("=") + ) + # Standard Base64 remains decoder tolerance, not conformant webhook-v1 + # emission. Only the selected label supplies the verified signature. + header = f"unused=:A+B-CDEF:, sig1=:{token}:" + assert ( + verify_webhook_signature( + method=request["method"], + url=request["url"], + headers={**request["headers"], "Signature": header}, + body=request["body"].encode(), + options=vector_options(vector), + ).label + == "sig1" + ) + + +@pytest.mark.parametrize("header", ['sig1="not-binary"', "sig1=:abcde:", "sig1=:abcde=:"]) +def test_malformed_binary_shape_and_padding_stay_step_one(header): + vector = json.loads(VECTORS.joinpath("positive/001-basic-post.json").read_text()) + request = vector["request"] + with pytest.raises(SignatureVerificationError) as raised: + verify_webhook_signature( + method=request["method"], + url=request["url"], + headers={**request["headers"], "Signature": header}, + body=request["body"].encode(), + options=vector_options(vector), + ) + assert (raised.value.code, raised.value.step) == ("webhook_signature_header_malformed", 1) diff --git a/tests/conformance/signing/test_webhook_signature_emission.py b/tests/conformance/signing/test_webhook_signature_emission.py new file mode 100644 index 000000000..8f049c378 --- /dev/null +++ b/tests/conformance/signing/test_webhook_signature_emission.py @@ -0,0 +1,82 @@ +"""Webhook-v1 emission stays distinct from request-signing profile 3.2.""" + +import base64 +import hashlib +import json +import re +from importlib.resources import files + +import pytest + +from adcp.signing import private_key_from_jwk, sign_request +from adcp.webhooks import WebhookVerifyOptions, sign_webhook, verify_webhook_signature + +KEYS = json.loads( + files("adcp") + .joinpath("_compliance/3.2.0-rc.6/test-vectors/webhook-signing/keys.json") + .read_text() +)["keys"] +ALGORITHMS = [ + ("ed25519", "test-ed25519-webhook-2026"), + ("ecdsa-p256-sha256", "test-es256-webhook-2026"), +] +BODY = '{\n "task_id": "raw-body", "text": "é", "status": "completed"\n}\n'.encode() +URL = "https://buyer.example.test/webhook" +NOW = 1776520800 + + +@pytest.mark.parametrize("alg,kid", ALGORITHMS) +@pytest.mark.parametrize("label", ["sig1", "callback"]) +def test_webhook_emits_unpadded_base64url_with_unchanged_digest(alg, kid, label): + key = next(row for row in KEYS if row["kid"] == kid) + signed = sign_webhook( + method="POST", + url=URL, + headers={"Content-Type": "application/json"}, + body=BODY, + private_key=private_key_from_jwk(key, d_field="_private_d_for_test_only"), + key_id=kid, + alg=alg, + created=NOW, + nonce="emission-regression", + label=label, + ) + assert re.fullmatch(rf"{label}=:[A-Za-z0-9_-]+:", signed.signature) + assert signed.content_digest == ( + "sha-256=:" + base64.b64encode(hashlib.sha256(BODY).digest()).decode() + ":" + ) + public = {k: v for k, v in key.items() if not k.startswith("_")} + verified = verify_webhook_signature( + method="POST", + url=URL, + headers={"Content-Type": "application/json", **signed.as_dict()}, + body=BODY, + options=WebhookVerifyOptions( + jwks_resolver={kid: public}.get, clock=lambda: NOW, label=label + ), + ) + assert (verified.key_id, verified.alg, verified.label) == (kid, alg, label) + + +@pytest.mark.parametrize("alg,kid", ALGORITHMS) +@pytest.mark.parametrize("profile", ["3.0", "3.1", "3.2"]) +def test_request_profile_encoding_is_independent(alg, kid, profile): + key = next(row for row in KEYS if row["kid"] == kid) + signed = sign_request( + method="POST", + url=URL, + headers={"Content-Type": "application/json"}, + body=BODY, + private_key=private_key_from_jwk(key, d_field="_private_d_for_test_only"), + key_id=kid, + alg=alg, + created=NOW, + nonce="request-emission-regression", + signing_profile_version=profile, + cover_content_digest=True, + ) + pattern = r"sig1=:[A-Za-z0-9+/]+==:" if profile == "3.2" else r"sig1=:[A-Za-z0-9_-]+:" + assert re.fullmatch(pattern, signed.signature) + assert signed.content_digest == ( + "sha-256=:" + base64.b64encode(hashlib.sha256(BODY).digest()).decode() + ":" + ) diff --git a/tests/conformance/signing/test_webhook_signature_http.py b/tests/conformance/signing/test_webhook_signature_http.py new file mode 100644 index 000000000..8558fed1e --- /dev/null +++ b/tests/conformance/signing/test_webhook_signature_http.py @@ -0,0 +1,201 @@ +"""Signed requests cross an actual socket into a separate public receiver.""" + +import asyncio +import base64 +import hashlib +import json +import os +import re +import signal +import socket +import subprocess +import sys +from contextlib import asynccontextmanager +from importlib.resources import files +from pathlib import Path + +import httpx +from cryptography.hazmat.primitives import serialization + +from adcp.signing import private_key_from_jwk +from adcp.webhooks import WebhookSender, create_mcp_webhook_payload, sign_webhook, to_wire_dict + + +@asynccontextmanager +async def receiver(tmp_path): + fixtures = Path(__file__).resolve().parents[3] + launcher = ( + "import sys; sys.path.insert(0, sys.argv.pop(1)); " + "from tests.conformance.signing._webhook_http_server import main; main()" + ) + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + sock.listen() + url = f"http://127.0.0.1:{sock.getsockname()[1]}/webhook" + with ( + (tmp_path / "server.stdout").open("xb") as out, + (tmp_path / "server.stderr").open("xb") as err, + ): + child = subprocess.Popen( + [ + sys.executable, + "-I", + "-c", + launcher, + str(fixtures), + str(tmp_path), + str(sock.fileno()), + ], + cwd=tmp_path, + pass_fds=(sock.fileno(),), + start_new_session=True, + stdout=out, + stderr=err, + ) + try: + for _ in range(1200): + if (tmp_path / "ready").exists(): + break + assert child.poll() is None, (tmp_path / "server.stderr").read_text() + await asyncio.sleep(0.05) + else: + raise AssertionError("webhook receiver startup exceeded 60 seconds") + yield url + finally: + if child.poll() is None: + os.killpg(child.pid, signal.SIGTERM) + try: + await asyncio.to_thread(child.wait, 15) + except subprocess.TimeoutExpired: + os.killpg(child.pid, signal.SIGKILL) + await asyncio.to_thread(child.wait) + assert (tmp_path / "stopped").is_file() + + +def keys(): + return json.loads( + files("adcp") + .joinpath("_compliance/3.2.0-rc.6/test-vectors/webhook-signing/keys.json") + .read_text() + )["keys"] + + +async def test_malformed_then_valid_signatures_over_http(tmp_path): + key = next(row for row in keys() if row["kid"] == "test-ed25519-webhook-2026") + async with receiver(tmp_path) as url, httpx.AsyncClient(timeout=30) as client: + for index, encoding in enumerate(("legacy_standard", "url")): + body = json.dumps( + to_wire_dict( + create_mcp_webhook_payload( + task_id=f"wire-signature-{index}", + task_type="create_media_buy", + operation_id=f"operation-{index}", + status="completed", + idempotency_key=f"webhook-boundary-{index}-unique-event", + ) + ) + ).encode() + signed = sign_webhook( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + private_key=private_key_from_jwk(key, d_field="_private_d_for_test_only"), + key_id=key["kid"], + alg="ed25519", + ) + headers = {"Content-Type": "application/json", **signed.as_dict()} + if encoding == "legacy_standard": + # Retained decoder tolerance is separate from webhook-v1 emission. + token = headers["Signature"].split(":")[1] + raw = base64.urlsafe_b64decode(token + "=" * (-len(token) % 4)) + headers["Signature"] = f"sig1=:{base64.b64encode(raw).decode()}:" + malformed = { + **headers, + "Signature": ( + "sig1=:A+B-CDEFGHIJKLMNOPQRSTUVWXYZ" "abcdefghijklmnopqrstuvwxyz0123456789AB:" + ), + } + denied = await client.post(url, content=body, headers=malformed) + assert denied.status_code == 401 + assert ( + 'Signature error="webhook_signature_header_malformed"' + in denied.headers["www-authenticate"] + ) + assert denied.json() == { + "key_lookups": index, + "processed": index, + "rejected": True, + "reason": "signature_invalid", + } + assert "A+B-CDE" not in denied.text + # The malformed request consumed neither nonce nor event. + accepted = await client.post(url, content=body, headers=headers) + assert accepted.status_code == 200, accepted.text + assert accepted.json() == { + "key_lookups": index + 1, + "processed": index + 1, + "rejected": False, + "reason": None, + } + + +async def test_public_sender_paths_emit_webhook_profile_over_http(tmp_path): + async with receiver(tmp_path) as url, httpx.AsyncClient(timeout=30) as client: + count = 0 + for alg, kid in ( + ("ed25519", "test-ed25519-webhook-2026"), + ("ecdsa-p256-sha256", "test-es256-webhook-2026"), + ): + key = next(row for row in keys() if row["kid"] == kid) + private_key = private_key_from_jwk(key, d_field="_private_d_for_test_only") + for entry in ("constructor", "jwk", "pem"): + if entry == "constructor": + sender = WebhookSender( + private_key=private_key, key_id=kid, alg=alg, client=client + ) + elif entry == "jwk": + sender = WebhookSender.from_jwk( + key, d_field="_private_d_for_test_only", client=client + ) + else: + pem = private_key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ) + sender = WebhookSender.from_pem(pem, key_id=kid, alg=alg, client=client) + async with sender: + parameters = dict( + url=url, + task_id=f"sender-{count}", + task_type="create_media_buy", + operation_id=f"operation-{count}", + status="completed", + ) + if entry == "constructor": + result = await sender.send_raw( + url=url, + idempotency_key=f"sender-emission-{count}", + payload=to_wire_dict( + create_mcp_webhook_payload( + task_id=f"sender-{count}", + task_type="create_media_buy", + operation_id=f"operation-{count}", + status="completed", + ) + ), + ) + elif entry == "jwk": + result = await sender.send_mcp(**parameters) + else: + result = await sender.send_prepared(sender.prepare_mcp(**parameters)) + count += 1 + assert result.ok and result.status_code == 200, result.response_body + records = [ + json.loads(row) + for row in (tmp_path / "received.jsonl").read_text().splitlines() + ] + assert len(records) == count + assert records[-1]["body_sha256"] == hashlib.sha256(result.sent_body).hexdigest() + assert re.fullmatch(r"sig1=:[A-Za-z0-9_-]+:", records[-1]["signature"]) diff --git a/tests/test_decisioning_dispatch.py b/tests/test_decisioning_dispatch.py index 357da99b0..045c77297 100644 --- a/tests/test_decisioning_dispatch.py +++ b/tests/test_decisioning_dispatch.py @@ -879,6 +879,40 @@ async def get_products(self, req, ctx): assert "eyJhbGciOiJIUzI1NiJ9" not in str(exc_info.value.details) +def test_internal_error_details_survive_a_narrowing_failure_without_raw_values(monkeypatch): + from pydantic import ValidationError + + from adcp.decisioning.dispatch import _internal_error_details + from adcp.types import error_narrowing + + class _InvalidResponse(BaseModel): + count: int + + with pytest.raises(ValidationError) as captured: + _InvalidResponse.model_validate({"count": "raw-input-marker"}) + observed = [] + + def broken_narrowing(errors): + observed.extend(errors) + yield {"msg": "partial-narrowing-marker"} + raise RuntimeError("raw-narrowing-failure-marker") + + monkeypatch.setattr(error_narrowing, "narrow_union_errors", broken_narrowing) + details = _internal_error_details(captured.value) + assert observed and all("input" not in item and "ctx" not in item for item in observed) + assert details == {"caused_by": {"type": "ValidationError"}} + # A partial generator result and either raw value must not enter the error + # response; the secondary narrowing failure must not replace the original. + assert all( + marker not in str(details) + for marker in ( + "raw-input-marker", + "partial-narrowing-marker", + "raw-narrowing-failure-marker", + ) + ) + + @pytest.mark.asyncio async def test_invoke_validation_error_surfaces_narrowed_field_paths( executor: ThreadPoolExecutor, @@ -1886,7 +1920,8 @@ async def _on_complete(result: Any) -> None: on_complete=_on_complete, ) ) - assert await asyncio.to_thread(entered.wait, 1) + correction_condition_1 = await asyncio.to_thread(entered.wait, 1) + assert correction_condition_1 task.cancel("client disconnected") with pytest.raises(asyncio.CancelledError): _ = await task @@ -1931,7 +1966,8 @@ async def _on_failure(exc: BaseException) -> None: on_failure=_on_failure, ) ) - assert await asyncio.to_thread(entered.wait, 1) + correction_condition_2 = await asyncio.to_thread(entered.wait, 1) + assert correction_condition_2 task.cancel() with pytest.raises(asyncio.CancelledError) as exc_info: await asyncio.gather(task) diff --git a/tests/test_reporting_exact_request_models.py b/tests/test_reporting_exact_request_models.py new file mode 100644 index 000000000..18e99260e --- /dev/null +++ b/tests/test_reporting_exact_request_models.py @@ -0,0 +1,133 @@ +"""Exact revision reads must not acquire aggregate-only selector defaults.""" + +import json +from copy import deepcopy + +import pytest +from pydantic import BaseModel, ValidationError + +from adcp.types import GetMediaBuyDeliveryRequest +from adcp.validation.schema_loader import get_named_validator + + +def exact_request(): + return { + "account": {"account_id": "acct_a"}, + "reporting_revision_id": "rpr_public_exact_revision", + "pagination": {"max_results": 100}, + } + + +@pytest.mark.parametrize("parser", ["model_validate", "model_validate_json"]) +def test_exact_revision_request_survives_public_serialization(parser): + raw = exact_request() + before = deepcopy(raw) + validator = get_named_validator("media-buy/get-media-buy-delivery-request.json") + assert validator is not None and not list(validator.iter_errors(raw)) + request = getattr(GetMediaBuyDeliveryRequest, parser)( + json.dumps(raw) if parser == "model_validate_json" else raw + ) + encoded = request.model_dump(mode="json", exclude_none=True) + assert "include_package_daily_breakdown" not in encoded + assert "include_window_breakdown" not in encoded + assert not list(validator.iter_errors(encoded)) + assert raw == before + + +AGGREGATE_SELECTORS = ( + "media_buy_ids", + "start_date", + "end_date", + "status_filter", + "requested_metrics", + "reporting_dimensions", + "attribution_window", + "include_package_daily_breakdown", + "time_granularity", + "include_window_breakdown", +) + + +@pytest.mark.parametrize("field", AGGREGATE_SELECTORS) +def test_explicit_aggregate_selectors_are_forbidden_even_when_null(field): + raw = {**exact_request(), field: None} + before = deepcopy(raw) + with pytest.raises(ValidationError, match="exact revision requests forbid aggregate selectors"): + GetMediaBuyDeliveryRequest.model_validate(raw) + assert raw == before + + +@pytest.mark.parametrize("field", ["include_package_daily_breakdown", "include_window_breakdown"]) +@pytest.mark.parametrize("flag", [False, True]) +def test_exact_mode_rejects_explicit_boolean_flags_without_mutation(field, flag): + raw = {**exact_request(), field: flag} + before = deepcopy(raw) + with pytest.raises(ValidationError, match="exact revision requests forbid aggregate selectors"): + GetMediaBuyDeliveryRequest.model_validate(raw) + assert raw == before + + +@pytest.mark.parametrize( + "flags", + [ + {}, + {"include_package_daily_breakdown": False}, + {"include_window_breakdown": False}, + {"include_package_daily_breakdown": True, "include_window_breakdown": True}, + ], +) +def test_aggregate_defaults_and_explicit_flags_are_preserved(flags): + raw = {"media_buy_ids": ["shared-media-buy"], **flags} + request = GetMediaBuyDeliveryRequest.model_validate(raw) + fields = set(request.model_fields_set) + for value in ( + request.model_dump(), + request.model_dump(mode="json"), + json.loads(request.model_dump_json()), + ): + assert value["include_package_daily_breakdown"] is flags.get( + "include_package_daily_breakdown", False + ) + assert value["include_window_breakdown"] is flags.get("include_window_breakdown", False) + validator = get_named_validator("media-buy/get-media-buy-delivery-request.json") + assert not list(validator.iter_errors(value)) + assert request.model_fields_set == fields + + +def test_pagination_requires_an_exact_revision(): + with pytest.raises(ValidationError, match="pagination requires reporting_revision_id"): + GetMediaBuyDeliveryRequest.model_validate({"pagination": {"max_results": 100}}) + + +def test_exact_request_subclasses_and_nesting_preserve_the_selected_mode(): + class Exact(GetMediaBuyDeliveryRequest): + pass + + class OrdinaryParent(BaseModel): + request: Exact + + raw = exact_request() + parent = OrdinaryParent.model_validate({"request": raw}) + fields = set(parent.request.model_fields_set) + for value in ( + parent.model_dump(), + parent.model_dump(mode="json"), + json.loads(parent.model_dump_json()), + {"request": parent.request.model_dump()}, + {"request": json.loads(parent.request.model_dump_json())}, + ): + assert not set(AGGREGATE_SELECTORS).intersection(value["request"]) + assert value["request"]["pagination"]["max_results"] == 100 + assert value["request"]["pagination"].get("cursor") is None + validator = get_named_validator("media-buy/get-media-buy-delivery-request.json") + assert not list( + validator.iter_errors(parent.model_dump(mode="json", exclude_none=True)["request"]) + ) + serialized = parent.request.model_dump(mode="json") + assert GetMediaBuyDeliveryRequest.model_validate(serialized).model_dump() == serialized + assert parent.request.model_fields_set == fields + # Aggregate-mode defaults remain available as attributes; serialization is + # mode-aware and never changes the model or a caller-owned input mapping. + assert parent.request.include_package_daily_breakdown is False + assert parent.request.include_window_breakdown is False + assert raw == exact_request() diff --git a/tests/test_reporting_feed_numeric_parameters.py b/tests/test_reporting_feed_numeric_parameters.py new file mode 100644 index 000000000..3c32eda43 --- /dev/null +++ b/tests/test_reporting_feed_numeric_parameters.py @@ -0,0 +1,126 @@ +"""Exact received JSON numbers must survive reporting filter binding.""" + +import json +from copy import deepcopy +from decimal import Decimal + +import pytest + +from adcp.reporting.feed.request import FeedRequest, transport_parameters +from adcp.reporting.receipts.transport import _raw_json, _receipt_numbers + + +def wire_request(lexeme): + return _raw_json( + '{"view":"periods","account":{"account_id":"acct_a"},"ext":{"vendor":{"n":' + lexeme + "}}}" + ) + + +def binding(lexeme): + return FeedRequest.parse(transport_parameters(wire_request(lexeme))).filters_json + + +@pytest.mark.parametrize( + "left,right", + [ + ("9007199254740993.0", "9007199254740992.0"), + ("9007199254740995.0", "9007199254740996.0"), + ("9.007199254740993e15", "9007199254740992"), + ], +) +def test_received_distinct_large_decimals_never_alias(left, right): + assert binding(left) != binding(right) + + +@pytest.mark.parametrize( + "integer,spelling", + [ + (1, "1.0"), + (10, "1e1"), + (9007199254740993, "9007199254740993.0"), + (9007199254740995, "9.007199254740995e15"), + (10**23, "1e23"), + ], +) +def test_integral_spellings_keep_the_exact_received_integer(integer, spelling): + assert binding(str(integer)) == binding(spelling) + decoded = transport_parameters(wire_request(spelling)) + assert type(decoded["ext"]["vendor"]["n"]) is int + assert decoded["ext"]["vendor"]["n"] == integer + + +@pytest.mark.parametrize("value", ["0.5", "4.8", "4.80", "-0.125", "1.25e-4"]) +def test_ordinary_finite_fractions_round_trip_without_mutating_the_request(value): + raw = wire_request(value) + before = deepcopy(raw) + normalized = transport_parameters(raw) + assert Decimal(repr(normalized["ext"]["vendor"]["n"])) == Decimal(value) + assert FeedRequest.parse(normalized).filters["ext"]["vendor"]["n"] == json.loads(value) + assert raw == before + + +@pytest.mark.parametrize( + "value", + [ + "0.100000000000000000001", + "9007199254740993.25", + "1.000000000000000000001", + "1e-400", + "1e400", + "NaN", + "Infinity", + "-Infinity", + ], +) +def test_unrepresentable_fraction_or_nonfinite_number_is_rejected(value): + with pytest.raises(ValueError): + transport_parameters(wire_request(value)) + + +@pytest.mark.parametrize( + "left,right,equal", + [ + (1, 1.0, True), + ({"n": [10]}, {"n": [10.0]}, True), + (0.5, 1, False), + (4.8, 4.80, True), + (True, 1, False), + (False, 0, False), + ({"b": True}, {"b": 1}, False), + (1, "1", False), + (1, 2, False), + ({"v": None}, {}, False), + (2**53 - 1, 2**53, False), + (2**53 + 1, 2**53, False), + ], +) +def test_existing_in_memory_json_equivalence_is_unchanged(left, right, equal): + def direct(value): + return FeedRequest.parse( + {"view": "periods", "account": {"account_id": "acct_a"}, "ext": {"v": value}} + ).filters_json + + assert (direct(left) == direct(right)) is equal + + +@pytest.mark.parametrize("value,expected", [("1", 1), ("1.0", 1), ("1e2", 100)]) +def test_page_limits_keep_exact_integer_equivalence(value, expected): + raw = wire_request("4.8") + raw["pagination"] = {"max_results": Decimal(value)} + result = transport_parameters(raw) + assert type(result["pagination"]["max_results"]) is int + assert FeedRequest.parse(result).limit == expected + + +@pytest.mark.parametrize("value", ["0", "101", "1.000000000000000000001", "1.5", "1e400", "NaN"]) +def test_page_limits_still_reject_out_of_range_or_inexact_values(value): + raw = wire_request("0.5") + raw["pagination"] = {"max_results": Decimal(value)} + with pytest.raises(ValueError): + transport_parameters(raw) + + +@pytest.mark.parametrize("value", ["9007199254740993.0", "0.5", "NaN", "1e400"]) +def test_financial_receipt_numbers_keep_their_stricter_contract(value): + with pytest.raises(ValueError): + _receipt_numbers(_raw_json(value)) diff --git a/tests/test_reporting_scope_models.py b/tests/test_reporting_scope_models.py new file mode 100644 index 000000000..752929534 --- /dev/null +++ b/tests/test_reporting_scope_models.py @@ -0,0 +1,156 @@ +"""Reporting selectors preserve the caller's scope through public typed models.""" + +import json +from copy import deepcopy +from types import MappingProxyType + +import pytest +from pydantic import BaseModel, ValidationError + +from adcp.types import ReportingDeliveryConfiguration, SyncAccountsRequest +from adcp.validation.schema_loader import get_named_validator + + +def onboarding_request(scope): + return { + "adcp_version": "3.2-rc.4", + "idempotency_key": "scope-onboarding-regression-0001", + "accounts": [ + { + "account": {"account_id": "usd"}, + "reporting_delivery_configs": [ + { + "delivery_config_id": "shared-config", + "delivery_config_version": 1, + "offering_id": "fixture-official-USD", + "active": False, + "feed_purpose": "billing", + "report_definition_id": "reference-report-v1", + "reporting_profile": "paid_media_delivery", + "scope": deepcopy(scope), + "coverage_requirement": "full", + "required_finality": "official", + "reconciliation_mode": "consumer_receipt", + "authoritative_party": "seller", + "schedule": { + "period_duration": "PT1H", + "alignment": "utc", + "delivery_sla": "PT1H", + }, + "method": { + "pattern": "warehouse_materialization", + "transport": "fixture-sql", + "orchestration": "producer_managed", + "destination": { + "mode": "provision", + "provider": {"domain": "fixture.example.test"}, + "location": "reporting/shared-destination", + }, + }, + } + ], + } + ], + "delete_missing": False, + "dry_run": False, + } + + +def test_explicit_media_buy_selector_survives_public_request_serialization(): + raw = onboarding_request({"media_buy_ids": ["shared-media-buy"]}) + before = deepcopy(raw) + validator = get_named_validator("account/sync-accounts-request.json") + assert validator is not None + assert not list(validator.iter_errors(raw)) + request = SyncAccountsRequest.model_validate(raw) + encoded = request.model_dump(mode="json", exclude_none=True) + assert encoded["accounts"][0]["reporting_delivery_configs"][0]["scope"] == { + "media_buy_ids": ["shared-media-buy"] + } + assert not list(validator.iter_errors(encoded)) + assert raw == before + + +@pytest.mark.parametrize( + "scope", [{}, {"all_media_buys": True}, {"media_buy_ids": ["shared-media-buy"]}] +) +def test_scope_modes_round_trip_without_mutating_inputs_or_field_presence(scope): + raw = onboarding_request(scope) + before = deepcopy(raw) + request = SyncAccountsRequest.model_validate(raw) + selected = request.accounts[0].reporting_delivery_configs[0].scope + expected = scope or {"all_media_buys": True} + fields = set(selected.model_fields_set) + assert selected.all_media_buys is (True if "all_media_buys" in expected else None) + validator = get_named_validator("account/sync-accounts-request.json") + for encoded in ( + request.model_dump(), + request.model_dump(mode="json"), + json.loads(request.model_dump_json()), + ): + assert encoded["accounts"][0]["reporting_delivery_configs"][0]["scope"] == expected + assert not list(validator.iter_errors(encoded)) + assert SyncAccountsRequest.model_validate(encoded).model_dump( + mode="json" + ) == request.model_dump(mode="json") + assert raw == before + assert selected.model_fields_set == fields + + +@pytest.mark.parametrize( + "scope", + [ + {"all_media_buys": False}, + {"all_media_buys": 1}, + {"all_media_buys": "true"}, + {"all_media_buys": None}, + {"media_buy_ids": None}, + {"media_buy_ids": []}, + {"media_buy_ids": [""]}, + {"media_buy_ids": ["shared-media-buy", "shared-media-buy"]}, + {"all_media_buys": True, "media_buy_ids": ["shared-media-buy"]}, + {"all_media_buys": True, "media_buy_ids": None}, + {"all_media_buys": None, "media_buy_ids": ["shared-media-buy"]}, + {"unexpected_selector": True}, + ], +) +def test_malformed_or_conflicting_explicit_scopes_fail_closed(scope): + raw = onboarding_request(scope) + before = deepcopy(raw) + with pytest.raises(ValidationError): + SyncAccountsRequest.model_validate(raw) + assert raw == before + + +@pytest.mark.parametrize( + "scope", [{}, {"all_media_buys": True}, {"media_buy_ids": ["shared-media-buy"]}] +) +def test_scope_subclasses_inside_an_ordinary_pydantic_parent(scope): + scope_type = ReportingDeliveryConfiguration.model_fields["scope"].annotation + + class CustomScope(scope_type): + pass + + class OrdinaryParent(BaseModel): + scope: CustomScope + + parent = OrdinaryParent.model_validate({"scope": scope}) + expected = scope or {"all_media_buys": True} + for encoded in ( + parent.model_dump(), + parent.model_dump(mode="json"), + json.loads(parent.model_dump_json()), + ): + assert encoded == {"scope": expected} + assert parent.scope.all_media_buys is (True if "all_media_buys" in expected else None) + + +@pytest.mark.parametrize( + "scope", [{}, {"media_buy_ids": ["shared-media-buy"]}, {"all_media_buys": True}] +) +def test_read_only_scope_mappings_follow_the_same_selector_contract(scope): + scope_type = ReportingDeliveryConfiguration.model_fields["scope"].annotation + wrapped = MappingProxyType(scope) + selected = scope_type.model_validate(wrapped) + assert selected.model_dump(mode="json") == (scope or {"all_media_buys": True}) + assert dict(wrapped) == scope diff --git a/tests/test_reporting_selector_generation.py b/tests/test_reporting_selector_generation.py new file mode 100644 index 000000000..11077bf12 --- /dev/null +++ b/tests/test_reporting_selector_generation.py @@ -0,0 +1,106 @@ +"""Code generation must preserve both reporting selector contracts.""" + +import ast +import json +import re +import sys +from pathlib import Path +from types import ModuleType + +import pytest +from pydantic import ValidationError + +from scripts import post_generate_fixes + +ROOT = Path(__file__).parents[1] / "src/adcp/types/generated_poc" +SOURCES = ( + ("core/reporting_delivery_config.py", "Scope", "adcp.types.generated_poc.core"), + ( + "media_buy/get_media_buy_delivery_request.py", + "GetMediaBuyDeliveryRequest", + "adcp.types.generated_poc.media_buy", + ), +) +METHODS = { + "_select_reporting_scope", + "_unique_reporting_scope", + "_serialize_reporting_scope", + "_validate_delivery_selector_mode", + "_serialize_delivery_selector_mode", +} + + +def unrepaired(source): + """Restore the two original codegen defaults without changing field schemas.""" + lines = source.splitlines(keepends=True) + cuts = [] + for node in ast.walk(ast.parse(source)): + if isinstance(node, ast.FunctionDef) and node.name in METHODS: + cuts.append((min(d.lineno for d in node.decorator_list) - 1, node.end_lineno)) + for start, end in sorted(cuts, reverse=True): + del lines[start:end] + return "".join(lines).replace( + "all_media_buys: Literal[True] | None = None", "all_media_buys: Literal[True] = True" + ) + + +def test_regeneration_repairs_canonical_and_self_contained_clones(tmp_path, monkeypatch): + targets = [] + for relative, class_name, package in SOURCES: + source = unrepaired((ROOT / relative).read_text()) + # Exercise fresh codegen without the repair's imports already present. + for added_import in ( + "from collections.abc import Mapping\n", + "from typing import Any\n", + ( + "from pydantic import SerializerFunctionWrapHandler, " + "model_serializer, model_validator\n" + ), + ): + source = source.replace(added_import, "") + for clone in (False, True): + name = class_name + ("2" if clone else "") + target = tmp_path / (("bundled/" if clone else "") + relative) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(re.sub(r"\b" + class_name + r"\b", name, source)) + targets.append((target, name, package)) + monkeypatch.setattr(post_generate_fixes, "OUTPUT_DIR", tmp_path) + post_generate_fixes.fix_reporting_request_selectors() + first = [p.read_bytes() for p, _, _ in targets] + post_generate_fixes.fix_reporting_request_selectors() + assert first == [p.read_bytes() for p, _, _ in targets] + for index, (target, name, package) in enumerate(targets): + module = ModuleType(package + ".selector_regeneration_" + str(index)) + module.__package__ = package + monkeypatch.setitem(sys.modules, module.__name__, module) + exec(compile(target.read_bytes(), str(target), "exec"), vars(module)) + model = getattr(module, name) + assert ("Mapping" in vars(module)) == name.startswith("Scope") + if name.startswith("Scope"): + explicit = model.model_validate({"media_buy_ids": ["shared-media-buy"]}) + assert json.loads(explicit.model_dump_json()) == {"media_buy_ids": ["shared-media-buy"]} + assert model().model_dump() == {"all_media_buys": True} + with pytest.raises(ValidationError): + model.model_validate( + {"all_media_buys": True, "media_buy_ids": ["shared-media-buy"]} + ) + else: + exact = {"account": {"account_id": "acct_a"}, "reporting_revision_id": "rpr_exact"} + value = model.model_validate(exact) + assert "include_package_daily_breakdown" not in value.model_dump() + assert "include_window_breakdown" not in json.loads(value.model_dump_json()) + with pytest.raises(ValidationError): + model.model_validate({**exact, "include_window_breakdown": False}) + assert model().model_dump()["include_window_breakdown"] is False + + +def test_changed_scope_schema_requires_an_explicit_generator_decision(tmp_path, monkeypatch): + schema = tmp_path / "core/reporting-delivery-config.json" + schema.parent.mkdir() + original = post_generate_fixes.SCHEMA_DIR / "core/reporting-delivery-config.json" + value = json.loads(original.read_bytes()) + value["properties"]["scope"]["maxProperties"] = 2 + schema.write_text(json.dumps(value)) + monkeypatch.setattr(post_generate_fixes, "SCHEMA_DIR", tmp_path) + with pytest.raises(ValueError, match="reporting scope schema changed"): + post_generate_fixes.fix_reporting_request_selectors()