diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f7505dd..c35425ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,29 @@ All notable changes to the IAB Tech Lab Seller Agent are documented here. helper (mirrors `interfaces.api.deps`, matching how `_registry_service` and `_api_key_service` already do this) instead of each re-instantiating the service independently. +- **Information disclosure:** `GET /proposals/{proposal_id}/negotiation` + no longer returns the seller's internal negotiation guardrails, and no + longer answers unauthenticated callers. The route previously had no auth + dependency and no response model, so any caller who knew or guessed a + proposal id received `strategy`, `base_price`, `floor_price` and + `max_rounds` — the seller's floor price and its remaining concession + budget. Operators running an earlier build should assume those values + were readable for every negotiation that existed on that build. The four + fields are now dropped from the service projection (so no consumer can + re-expose them), the response shape is pinned by + `NegotiationStatusResponse`, and the route resolves a verified buyer + context like its sibling negotiation routes, rejecting anonymous callers + with 401. The shared `Negotiation` primitive excludes the same four + fields deliberately. The `rounds` array is likewise typed + (`NegotiationRoundView`) rather than passed through as raw round dumps: + each internal round carries `cumulative_concession_pct`, from which + `seller_price / (1 - cumulative_concession_pct)` reconstructs + `base_price` exactly, and a `rationale` that can state the floor in + prose — both are excluded at the wire. Note that the response is now + authentication-scoped but not yet buyer-scoped: an authenticated buyer + can still read any proposal's negotiation, because the stored history + records no buyer identity to scope against. + - Map internal deal status to the shared wire enum on read; deals created via from-template, bulk, or curated paths no longer 500 on GET (#73). Internal `confirmed` reads as `booked`, internal diff --git a/docs/api/openapi.json b/docs/api/openapi.json index 78194fde..4711c219 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -120,6 +120,38 @@ "default": 50, "title": "Limit" } + }, + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "name": "X-Api-Key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Key" + } } ], "responses": { @@ -161,6 +193,38 @@ "type": "string", "title": "Event Id" } + }, + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "name": "X-Api-Key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Key" + } } ], "responses": { @@ -186,6 +250,78 @@ } }, "/auth/api-keys": { + "post": { + "tags": [ + "Authentication" + ], + "summary": "Create Api Key", + "description": "Create a new BUYER-role API key for a buyer identity.\n\nRequires an operator credential. Operator keys are created via\n``POST /auth/api-keys/operator``.\n\nThe response contains the full API key which is shown ONLY ONCE.\nStore it securely \u2014 it cannot be retrieved again.", + "operationId": "create_api_key_auth_api_keys_post", + "parameters": [ + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "name": "X-Api-Key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Key" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateApiKeyRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, "get": { "tags": [ "Authentication" @@ -193,6 +329,40 @@ "summary": "List Api Keys", "description": "List all API keys (metadata only, no secrets).", "operationId": "list_api_keys_auth_api_keys_get", + "parameters": [ + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "name": "X-Api-Key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Key" + } + } + ], "responses": { "200": { "description": "Successful Response", @@ -201,25 +371,71 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } - }, + } + }, + "/auth/api-keys/operator": { "post": { "tags": [ "Authentication" ], - "summary": "Create Api Key", - "description": "Create a new API key for a buyer.\n\nThe response contains the full API key which is shown ONLY ONCE.\nStore it securely \u2014 it cannot be retrieved again.", - "operationId": "create_api_key_auth_api_keys_post", + "summary": "Create Operator Api Key", + "description": "Create a new OPERATOR-role API key (no buyer identity).\n\nRequires an existing operator credential. Bootstrap the FIRST\noperator key with ``ad-seller create-operator-key`` (writes directly\nto storage, no network surface).\n\nThe response contains the full API key which is shown ONLY ONCE.\nStore it securely \u2014 it cannot be retrieved again.", + "operationId": "create_operator_api_key_auth_api_keys_operator_post", + "parameters": [ + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "name": "X-Api-Key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Key" + } + } + ], "requestBody": { + "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateApiKeyRequest" + "$ref": "#/components/schemas/CreateOperatorApiKeyRequest" } } - }, - "required": true + } }, "responses": { "200": { @@ -260,6 +476,38 @@ "type": "string", "title": "Key Id" } + }, + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "name": "X-Api-Key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Key" + } } ], "responses": { @@ -299,6 +547,38 @@ "type": "string", "title": "Key Id" } + }, + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "name": "X-Api-Key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Key" + } } ], "responses": { @@ -351,7 +631,7 @@ "Pricing" ], "summary": "Get Rate Card", - "description": "Get the current rate card (base CPMs by inventory type).\n\nThe rate card drives floor pricing during inventory sync and\ndeal creation. Can be updated via PUT to reflect ad server rate cards.", + "description": "Get the current rate card (base CPMs by inventory type).\n\nMatching entries override the catalog product's base CPM as the\nstarting price for quotes, from-template bookings, and negotiation\nanchors (floors still apply) \u2014 see ``rate_card_service`` (issue #69).\nCan be updated via PUT to reflect ad server rate cards.\n\n``source: \"defaults\"`` means no rate card has ever been stored for\nthis seller \u2014 the entries below are generic reference values, not an\noperator-configured card, and they do NOT drive pricing (a stored\nentry has to match a product's inventory type to take effect).\n``source: \"stored\"`` is the operator's actual card.", "operationId": "get_rate_card_api_v1_rate_card_get", "responses": { "200": { @@ -369,21 +649,55 @@ "Pricing" ], "summary": "Update Rate Card", - "description": "Update the rate card with current base CPMs from ad server.\n\nPublishers should update this when their ad server rate cards change.\nThe pricing engine uses these values as base prices before applying\ntier discounts and volume adjustments.", + "description": "Update the rate card with current base CPMs from ad server.\n\nPublishers should update this when their ad server rate cards change.\nAn entry whose ``inventory_type`` matches a catalog product overrides\nthat product's base CPM as the starting price for quotes,\nfrom-template bookings, and negotiation anchors \u2014 the product's floor\nCPM still applies (issue #69; see ``rate_card_service``). Products\nwith no matching entry keep pricing from the catalog, unchanged.", "operationId": "update_rate_card_api_v1_rate_card_put", + "parameters": [ + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "name": "X-Api-Key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Key" + } + } + ], "requestBody": { + "required": true, "content": { "application/json": { "schema": { + "type": "array", "items": { "$ref": "#/components/schemas/RateCardEntry" }, - "type": "array", "title": "Entries" } } - }, - "required": true + } }, "responses": { "200": { @@ -445,6 +759,38 @@ "default": false, "title": "Incremental" } + }, + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "name": "X-Api-Key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Key" + } } ], "responses": { @@ -978,7 +1324,7 @@ "Products" ], "summary": "Override Inventory Type", - "description": "Override the auto-detected inventory type for a product.\n\nPublishers can correct misclassified inventory types from ad server sync\nor apply custom categorization. The override persists across future syncs.", + "description": "Override the auto-detected inventory type for a product.\n\nPublishers can correct misclassified inventory types from ad server sync\nor apply custom categorization. The override persists across future syncs.\nRequires an operator credential (catalog mutation).", "operationId": "override_inventory_type_api_v1_products__product_id__inventory_type_post", "parameters": [ { @@ -1098,7 +1444,7 @@ "Products" ], "summary": "Delete Inventory Type Override", - "description": "Remove an inventory type override, reverting to auto-detected type.", + "description": "Remove an inventory type override, reverting to auto-detected type.\n\nRequires an operator credential (catalog mutation).", "operationId": "delete_inventory_type_override_api_v1_products__product_id__inventory_type_delete", "parameters": [ { @@ -1109,6 +1455,38 @@ "type": "string", "title": "Product Id" } + }, + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "name": "X-Api-Key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Key" + } } ], "responses": { @@ -1298,7 +1676,7 @@ "Negotiation" ], "summary": "Get Negotiation Status", - "description": "Get full negotiation history for a proposal.", + "description": "Get the negotiation status for a proposal.\n\nInformation disclosure fix: this route previously had NO auth\ndependency and no ``response_model``, and answered with whatever the\nservice produced \u2014 which included the seller's ``strategy``,\n``base_price``, ``floor_price`` and ``limits.max_rounds``. Any caller\nwho knew or guessed a proposal id learned the seller's floor and its\nremaining concession budget, the two things a negotiating seller must\nnever reveal. The service no longer projects those fields and\n``NegotiationStatusResponse`` pins the wire shape.\n\nAuth now matches the sibling negotiation routes (``POST\n/proposals/{proposal_id}/counter``, ``POST\n/api/v1/negotiations/messages``): the caller's API key is resolved and\nrun through the EP-5.2 verified-buyer-context path. A GET carries no\nbody identity to self-assert, so an anonymous caller cannot be verified\nas a party to the negotiation and is rejected with 401 rather than\nhanded another buyer's negotiation state.", "operationId": "get_negotiation_status_proposals__proposal_id__negotiation_get", "parameters": [ { @@ -1309,6 +1687,38 @@ "type": "string", "title": "Proposal Id" } + }, + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "name": "X-Api-Key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Key" + } } ], "responses": { @@ -1316,7 +1726,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/NegotiationStatusResponse" + } } } }, @@ -1339,7 +1751,7 @@ "Negotiation" ], "summary": "Post Negotiation Message", - "description": "Canonical negotiation surface \u2014 accepts the shared NegotiationMessage.\n\nThe seller now validates the SAME message the buyer emits: a required\n``action`` enum (accept/counter/reject/final_offer) plus ``buyer_price``\nas :class:`Money`. A well-formed counter no longer 422s (the historical\nbug). Priced actions (counter/final_offer) run the seller's untouched\nnegotiation engine; accept/reject are recorded as terminal rounds off\nthe existing history.", + "description": "Canonical negotiation surface \u2014 accepts the shared NegotiationMessage.\n\nThe seller now validates the SAME message the buyer emits: a required\n``action`` enum (accept/counter/reject/final_offer) plus ``buyer_price``\nas :class:`Money`. A well-formed counter no longer 422s (the historical\nbug). Priced actions (counter/final_offer) run the seller's untouched\nnegotiation engine; accept/reject are recorded as terminal rounds off\nthe existing history.\n\n**Idempotency (FD-12):** the request carries a required\n``idempotency_key``. A replay with a key already used for an identical\nbody returns the same round without consuming another one from the\nnegotiation's ``max_rounds`` budget; the same key reused with a\ndifferent body is an ``idempotency_conflict`` (HTTP 409). The key is\nscoped per buyer, same as ``/api/v1/quotes`` and ``/api/v1/deals``.", "operationId": "post_negotiation_message_api_v1_negotiations_messages_post", "parameters": [ { @@ -1457,7 +1869,7 @@ "Deal Booking" ], "summary": "Book Deal", - "description": "Book a deal from a previously issued quote.\n\nThe seller validates the quote, generates a Deal ID, and returns\nconfirmed terms. This is the commit point \u2014 the quote becomes bound.\n\n**Wire format (proposal \u00a75.6 + \u00a76 row 14b):** the seller accepts both\naudience-plan content types --\n``application/vnd.ucp.embedding+json; v=1`` (legacy UCP carrier) and\n``application/vnd.iab.agentic-audiences+json; v=1`` (new IAB Agentic\nAudiences alias). FastAPI's body parsing is content-type-permissive, so\nboth names round-trip the same Pydantic model with no custom dependency\nneeded; the dual acceptance is exercised by\n``tests/unit/test_deal_booking_snapshot.py``.\n\n**Snapshot (proposal \u00a75.1 Step 2 + wire-format \u00a76.5):** when the request\ncarries an ``audience_plan``, the seller persists it verbatim as\n``audience_plan_snapshot`` against the deal record and returns the\nsnapshot plus a per-role ``audience_match_summary`` so the buyer can\nverify the booking. The snapshot is authoritative for the lifetime of\nthe deal -- if seller capabilities change mid-flight, the snapshot is\nhonored (see ``services/fulfillment.honor_audience_plan_snapshot``).\n\n**Forensic logging (proposal \u00a75.1 Step 2):** the\n``audience_plan_id`` hash is logged at INFO via\n``ad_seller.audience.booking``. The buyer logs the same hash on its\nside; matching entries are the cross-system anchor for dispute\nresolution.\n\n**Idempotency (FD-12):** the request carries a required\n``idempotency_key``. A replay with a key already booked returns the\nsame Deal without minting a second one (no duplicate side effect).", + "description": "Book a deal from a previously issued quote.\n\nThe seller validates the quote, generates a Deal ID, and returns\nconfirmed terms. This is the commit point \u2014 the quote becomes bound.\n\n**Wire format (proposal \u00a75.6 + \u00a76 row 14b):** the seller accepts both\naudience-plan content types --\n``application/vnd.ucp.embedding+json; v=1`` (legacy UCP carrier) and\n``application/vnd.iab.agentic-audiences+json; v=1`` (new IAB Agentic\nAudiences alias). FastAPI's body parsing is content-type-permissive, so\nboth names round-trip the same Pydantic model with no custom dependency\nneeded; the dual acceptance is exercised by\n``tests/unit/test_deal_booking_snapshot.py``.\n\n**Snapshot (proposal \u00a75.1 Step 2 + wire-format \u00a76.5):** when the request\ncarries an ``audience_plan``, the seller persists it verbatim as\n``audience_plan_snapshot`` against the deal record and returns the\nsnapshot plus a per-role ``audience_match_summary`` so the buyer can\nverify the booking. The snapshot is authoritative for the lifetime of\nthe deal -- if seller capabilities change mid-flight, the snapshot is\nhonored (see ``services/fulfillment.honor_audience_plan_snapshot``).\n\n**Forensic logging (proposal \u00a75.1 Step 2):** the\n``audience_plan_id`` hash is logged at INFO via\n``ad_seller.audience.booking``. The buyer logs the same hash on its\nside; matching entries are the cross-system anchor for dispute\nresolution.\n\n**Authentication (security fix):** every other money-moving path\n(quotes, proposals, negotiation, template booking) requires a verified\nbuyer via ``deps._verified_buyer_context``; this route was deliberately\nskipped when that wave landed (July 2026, EP-5.2) because it books at\nthe already-capped quoted price -- price integrity was covered, but the\ncaller's identity was not. An OPTIONAL key was used only to scope the\nidempotency namespace, so anyone who learned a ``quote_id`` could book\nit at another buyer's negotiated tier pricing. A verified buyer is now\nREQUIRED (401 if absent), and the caller must be the buyer the\nreferenced quote was priced for -- both by recorded identity (when the\nquote's origin is on record via ``QuoteHistoryStore``) and by tier\n(403 ``buyer_mismatch`` / ``buyer_tier_mismatch`` otherwise). A quote\npriced at the PUBLIC tier (no identity asserted at quote time) has\nnothing buyer-specific to steal, so any authenticated buyer may book it.\n\n**Idempotency (FD-12):** the request carries a required\n``idempotency_key``. A replay with a key already used for an identical\nbody returns the same Deal without minting a second one (no duplicate\nside effect); the same key reused with a different body is an\n``idempotency_conflict`` (HTTP 409). The key is scoped per buyer\n(FD-12) \u2014 the now-required API key identity takes priority, matching\nthe priority order used to resolve buyer identity elsewhere.", "operationId": "book_deal_api_v1_deals_post", "parameters": [ { @@ -1869,15 +2281,49 @@ "summary": "Push Deal To Buyers", "description": "Push a deal to one or more buyer endpoints via IAB Deals API v1.0.\n\nThe seller sends deal terms to buyer DSPs. Each buyer receives an\nHTTP POST with the full IAB Deal object and responds with acceptance status.\n\nThis is the standardized deal distribution path \u2014 alternative to\nSSP-mediated distribution (PubMatic, Index Exchange, etc.).", "operationId": "push_deal_to_buyers_api_v1_deals_push_post", + "parameters": [ + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "name": "X-Api-Key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Key" + } + } + ], "requestBody": { + "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DealPushRequest" } } - }, - "required": true + } }, "responses": { "200": { @@ -1945,29 +2391,63 @@ "schema": { "$ref": "#/components/schemas/HTTPValidationError" } - } + } + } + } + } + } + }, + "/api/v1/deals/distribute": { + "post": { + "tags": [ + "Deal Booking" + ], + "summary": "Distribute Deal Via Ssp", + "description": "Distribute a deal through configured SSP(s).\n\nRoutes the deal to the appropriate SSP based on routing rules\nor explicit ssp_name. The SSP handles DSP-side distribution.\n\nSupports multiple SSPs: PubMatic (MCP), Index Exchange (REST),\nMagnite (REST), or any configured SSP connector.", + "operationId": "distribute_deal_via_ssp_api_v1_deals_distribute_post", + "parameters": [ + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "name": "X-Api-Key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Key" } } - } - } - }, - "/api/v1/deals/distribute": { - "post": { - "tags": [ - "Deal Booking" ], - "summary": "Distribute Deal Via Ssp", - "description": "Distribute a deal through configured SSP(s).\n\nRoutes the deal to the appropriate SSP based on routing rules\nor explicit ssp_name. The SSP handles DSP-side distribution.\n\nSupports multiple SSPs: PubMatic (MCP), Index Exchange (REST),\nMagnite (REST), or any configured SSP connector.", - "operationId": "distribute_deal_via_ssp_api_v1_deals_distribute_post", "requestBody": { + "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SSPDealDistributeRequest" } } - }, - "required": true + } }, "responses": { "200": { @@ -2067,15 +2547,49 @@ "summary": "Register Curator", "description": "Register a new curator.\n\nCurators can then create deals against this publisher's inventory\nvia the /api/v1/deals/curated endpoint.", "operationId": "register_curator_api_v1_curators_post", + "parameters": [ + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "name": "X-Api-Key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Key" + } + } + ], "requestBody": { + "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CuratorRegistrationRequest" } } - }, - "required": true + } }, "responses": { "201": { @@ -3334,6 +3848,40 @@ "summary": "Create Package", "description": "Create a curated package (Layer 2).", "operationId": "create_package_packages_post", + "parameters": [ + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "name": "X-Api-Key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Key" + } + } + ], "requestBody": { "required": true, "content": { @@ -3496,6 +4044,38 @@ "type": "string", "title": "Package Id" } + }, + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "name": "X-Api-Key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Key" + } } ], "requestBody": { @@ -3547,6 +4127,38 @@ "type": "string", "title": "Package Id" } + }, + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "name": "X-Api-Key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Key" + } } ], "responses": { @@ -3579,15 +4191,49 @@ "summary": "Assemble Package", "description": "Assemble a dynamic package (Layer 3) from product IDs.\n\nProduct ids resolve catalog-first with storage fallback (issue #34) \u2014\nthe same resolution as ``POST /packages``. Zero resolution is a 422\nnaming the unresolved ids; partial resolution surfaces\n``unresolved_ids``/``warnings`` on the response.", "operationId": "assemble_package_packages_assemble_post", + "parameters": [ + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "name": "X-Api-Key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Key" + } + } + ], "requestBody": { + "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DynamicPackageRequest" } } - }, - "required": true + } }, "responses": { "200": { @@ -3611,26 +4257,70 @@ } } }, - "/packages/sync": { - "post": { - "tags": [ - "Packages" - ], - "summary": "Sync Packages", - "description": "Trigger ad server inventory sync (Layer 1).", - "operationId": "sync_packages_packages_sync_post", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - } - } - } - }, + "/packages/sync": { + "post": { + "tags": [ + "Packages" + ], + "summary": "Sync Packages", + "description": "Trigger ad server inventory sync (Layer 1).", + "operationId": "sync_packages_packages_sync_post", + "parameters": [ + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "name": "X-Api-Key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Key" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/.well-known/agent.json": { "get": { "tags": [ @@ -3771,6 +4461,38 @@ "type": "string", "title": "Agent Id" } + }, + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "name": "X-Api-Key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Key" + } } ], "responses": { @@ -3803,15 +4525,49 @@ "summary": "Discover Agent", "description": "Discover an agent by URL.\n\nFetches the agent's card from .well-known/agent.json, checks\nall configured registries (AAMP + extras) for verification, and\nregisters the agent locally with appropriate trust status.", "operationId": "discover_agent_registry_agents_discover_post", + "parameters": [ + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "name": "X-Api-Key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Key" + } + } + ], "requestBody": { + "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DiscoverAgentRequest" } } - }, - "required": true + } }, "responses": { "200": { @@ -3852,6 +4608,38 @@ "type": "string", "title": "Agent Id" } + }, + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "name": "X-Api-Key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Key" + } } ], "requestBody": { @@ -3892,7 +4680,7 @@ "Quotes" ], "summary": "Create Quote", - "description": "Request a non-binding price quote from the seller.\n\nThe seller evaluates the request against existing pricing rules and\nreturns a quote with pricing, terms, and availability. Quotes are\nephemeral with a 24-hour TTL \u2014 no Deal ID is created.", + "description": "Request a non-binding price quote from the seller.\n\nThe seller evaluates the request against existing pricing rules and\nreturns a quote with pricing, terms, and availability. Quotes are\nephemeral with a 24-hour TTL \u2014 no Deal ID is created.\n\n**Idempotency (FD-12):** the request carries a required\n``idempotency_key``. A replay with a key already used for an identical\nbody returns the same Quote without minting a second one; the same key\nreused with a different body is an ``idempotency_conflict`` (HTTP 409).\nThe key is scoped per buyer (FD-12) \u2014 buyer verification runs before\nthe replay lookup so a second buyer can never ride an unverified\nreplay of the first buyer's key.", "operationId": "create_quote_api_v1_quotes_post", "parameters": [ { @@ -4406,7 +5194,7 @@ "Orders" ], "summary": "Transition Order", - "description": "Transition an order to a new state.\n\nValidates the transition against the state machine rules and\nrecords the change in the audit log.", + "description": "Transition an order to a new state.\n\nValidates the transition against the state machine rules and\nrecords the change in the audit log. Requires an operator\ncredential \u2014 state transitions drive billing-relevant lifecycle.", "operationId": "transition_order_api_v1_orders__order_id__transition_post", "parameters": [ { @@ -4610,7 +5398,7 @@ "Change Requests" ], "summary": "Create Change Request", - "description": "Submit a change request for an existing order.\n\nValidates the change against the current order state, classifies\nseverity, and routes to approval if needed.", + "description": "Submit a change request for an existing order.\n\nValidates the change against the current order state, classifies\nseverity, and routes to approval if needed.\n\n**Idempotency (FD-12):** the request carries a required\n``idempotency_key``. An identical replay returns the original change\nrequest without creating a second approval; reusing the key with a\ndifferent body returns ``idempotency_conflict`` (HTTP 409). Keys are\nscoped per order and expire after 24 hours.", "operationId": "create_change_request_api_v1_change_requests_post", "parameters": [ { @@ -4851,7 +5639,7 @@ "Change Requests" ], "summary": "Review Change Request", - "description": "Approve or reject a pending change request.", + "description": "Approve or reject a pending change request.\n\nRequires an operator credential \u2014 the decision is the seller's, not\nthe requesting buyer's.", "operationId": "review_change_request_api_v1_change_requests__cr_id__review_post", "parameters": [ { @@ -4934,7 +5722,7 @@ "Change Requests" ], "summary": "Apply Change Request", - "description": "Apply an approved change request to the order.\n\nUpdates the order with the proposed values from the change request.", + "description": "Apply an approved change request to the order.\n\nUpdates the order with the proposed values from the change request.\nRequires an operator credential (order mutation).", "operationId": "apply_change_request_api_v1_change_requests__cr_id__apply_post", "parameters": [ { @@ -6073,10 +6861,15 @@ }, "type": "object", "title": "CreateApiKeyRequest", - "description": "Request to create a new API key for a buyer." + "description": "Request to create a new BUYER-role API key.\n\nOperator keys are created via POST /auth/api-keys/operator\n(CreateOperatorApiKeyRequest) \u2014 they carry no buyer identity." }, "CreateChangeRequestModel": { "properties": { + "idempotency_key": { + "type": "string", + "minLength": 1, + "title": "Idempotency Key" + }, "order_id": { "type": "string", "title": "Order Id" @@ -6118,12 +6911,36 @@ }, "type": "object", "required": [ + "idempotency_key", "order_id", "change_type" ], "title": "CreateChangeRequestModel", "description": "Request to create a change request for an order." }, + "CreateOperatorApiKeyRequest": { + "properties": { + "label": { + "type": "string", + "title": "Label", + "default": "" + }, + "expires_in_days": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Expires In Days" + } + }, + "type": "object", + "title": "CreateOperatorApiKeyRequest", + "description": "Request to create a new OPERATOR-role API key (no buyer identity)." + }, "CreateOrderRequest": { "properties": { "deal_id": { @@ -7845,6 +8662,46 @@ "title": "NegotiationRoundResponse", "description": "The seller's answer to a :class:`NegotiationMessage`.\n\nEmbeds the shared\n:class:`~iab_agentic_primitives.primitives.NegotiationRound` primitive\n\u2014 the same record appended to the Negotiation history \u2014 so the two\nsides cannot disagree about what a round contains. ``round.action`` is\nthe SELLER's move (accept/counter/reject/final_offer); when\n``status`` is terminal the round is the last one." }, + "NegotiationRoundView": { + "properties": { + "round_number": { + "type": "integer", + "title": "Round Number" + }, + "buyer_price": { + "type": "number", + "title": "Buyer Price" + }, + "seller_price": { + "type": "number", + "title": "Seller Price" + }, + "action": { + "type": "string", + "title": "Action" + }, + "timestamp": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Timestamp" + } + }, + "type": "object", + "required": [ + "round_number", + "buyer_price", + "seller_price", + "action" + ], + "title": "NegotiationRoundView", + "description": "A single negotiation round as the counterparty is allowed to see it.\n\nA whitelist, NOT a dump of the internal ``NegotiationRound``. Two of the\ninternal round fields undo the very redaction ``NegotiationStatusResponse``\nexists to enforce, so they are excluded here:\n\n- ``concession_pct`` / ``cumulative_concession_pct``: the engine computes\n the cumulative figure as ``(base_price - counter) / base_price``, so\n ``seller_price / (1 - cumulative_concession_pct)`` reconstructs the\n seller's ``base_price`` EXACTLY. Serializing it hands back the anchor\n the top level just dropped.\n- ``rationale``: on a below-floor offer the engine's rationale states the\n floor price in prose (\"Countering at the floor price $X CPM ...\").\n\nExcluding ``rationale`` here is boundary filtering on this read endpoint\nonly; whether the engine should put the floor in rationale text at all is\na separate, still-open decision." + }, "NegotiationStatus": { "type": "string", "enum": [ @@ -7856,6 +8713,91 @@ "title": "NegotiationStatus", "description": "Status of a negotiation (typed; was a raw string in the seller repo)." }, + "NegotiationStatusResponse": { + "properties": { + "negotiation_id": { + "type": "string", + "title": "Negotiation Id" + }, + "proposal_id": { + "type": "string", + "title": "Proposal Id" + }, + "quote_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Quote Id" + }, + "product_id": { + "type": "string", + "title": "Product Id" + }, + "buyer_tier": { + "type": "string", + "title": "Buyer Tier" + }, + "status": { + "type": "string", + "title": "Status" + }, + "total_rounds": { + "type": "integer", + "title": "Total Rounds" + }, + "rounds": { + "items": { + "$ref": "#/components/schemas/NegotiationRoundView" + }, + "type": "array", + "title": "Rounds", + "default": [] + }, + "started_at": { + "type": "string", + "title": "Started At" + }, + "completed_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Completed At" + }, + "package_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Package Id" + } + }, + "type": "object", + "required": [ + "negotiation_id", + "proposal_id", + "product_id", + "buyer_tier", + "status", + "total_rounds", + "started_at" + ], + "title": "NegotiationStatusResponse", + "description": "Buyer-facing negotiation status for ``GET /proposals/{id}/negotiation``.\n\nDeliberately NOT a dump of the internal ``NegotiationHistory``. The\nseller's ``strategy``, ``base_price``, ``floor_price`` and\n``NegotiationLimits`` (``max_rounds``) are internal guardrails and must\nnever cross the wire \u2014 the shared ``Negotiation`` primitive excludes all\nfour for exactly that reason. Pinning the wire shape here also keeps a\nlater addition to the service's history dict from silently re-leaking\nthem.\n\n``rounds`` is typed (``NegotiationRoundView``) rather than passed through\nas raw dicts: the internal round dumps carry ``cumulative_concession_pct``\n(which reconstructs ``base_price`` exactly) and ``rationale`` (which can\nstate the floor in prose). The service keeps projecting full rounds \u2014 its\ninternal consumer (``terminal_round_response``) needs them \u2014 and this\nmodel drops the unsafe fields at the wire." + }, "OpenRTBParams": { "properties": { "id": { @@ -9326,6 +10268,7 @@ }, "base_cpm": { "type": "number", + "exclusiveMinimum": 0.0, "title": "Base Cpm" }, "currency": { diff --git a/src/ad_seller/interfaces/api/routers/negotiation.py b/src/ad_seller/interfaces/api/routers/negotiation.py index 41427ad2..a6ecceb8 100644 --- a/src/ad_seller/interfaces/api/routers/negotiation.py +++ b/src/ad_seller/interfaces/api/routers/negotiation.py @@ -23,7 +23,12 @@ from ....services import negotiation_service from .. import contract_mappers as cm from .. import deps -from ..schemas import CounterOfferRequest, ProposalRequest, ProposalResponse +from ..schemas import ( + CounterOfferRequest, + NegotiationStatusResponse, + ProposalRequest, + ProposalResponse, +) router = APIRouter() @@ -84,9 +89,45 @@ async def counter_proposal( ) -@router.get("/proposals/{proposal_id}/negotiation", tags=["Negotiation"]) -async def get_negotiation_status(proposal_id: str): - """Get full negotiation history for a proposal.""" +@router.get( + "/proposals/{proposal_id}/negotiation", + response_model=NegotiationStatusResponse, + tags=["Negotiation"], +) +async def get_negotiation_status( + proposal_id: str, + api_key_record=Depends(deps._get_optional_api_key_record), +): + """Get the negotiation status for a proposal. + + Information disclosure fix: this route previously had NO auth + dependency and no ``response_model``, and answered with whatever the + service produced — which included the seller's ``strategy``, + ``base_price``, ``floor_price`` and ``limits.max_rounds``. Any caller + who knew or guessed a proposal id learned the seller's floor and its + remaining concession budget, the two things a negotiating seller must + never reveal. The service no longer projects those fields and + ``NegotiationStatusResponse`` pins the wire shape. + + Auth now matches the sibling negotiation routes (``POST + /proposals/{proposal_id}/counter``, ``POST + /api/v1/negotiations/messages``): the caller's API key is resolved and + run through the EP-5.2 verified-buyer-context path. A GET carries no + body identity to self-assert, so an anonymous caller cannot be verified + as a party to the negotiation and is rejected with 401 rather than + handed another buyer's negotiation state. + """ + buyer_context = await deps._verified_buyer_context( + endpoint="GET /proposals/{proposal_id}/negotiation", + api_key_record=api_key_record, + ) + if not buyer_context.is_authenticated: + raise HTTPException( + status_code=401, + detail="Authentication required", + headers={"WWW-Authenticate": "Bearer"}, + ) + return await negotiation_service.get_negotiation_status(proposal_id) diff --git a/src/ad_seller/interfaces/api/schemas.py b/src/ad_seller/interfaces/api/schemas.py index 9b693b03..358d96da 100644 --- a/src/ad_seller/interfaces/api/schemas.py +++ b/src/ad_seller/interfaces/api/schemas.py @@ -216,6 +216,68 @@ class CounterOfferRequest(BaseModel): agent_url: Optional[str] = None +class NegotiationRoundView(BaseModel): + """A single negotiation round as the counterparty is allowed to see it. + + A whitelist, NOT a dump of the internal ``NegotiationRound``. Two of the + internal round fields undo the very redaction ``NegotiationStatusResponse`` + exists to enforce, so they are excluded here: + + - ``concession_pct`` / ``cumulative_concession_pct``: the engine computes + the cumulative figure as ``(base_price - counter) / base_price``, so + ``seller_price / (1 - cumulative_concession_pct)`` reconstructs the + seller's ``base_price`` EXACTLY. Serializing it hands back the anchor + the top level just dropped. + - ``rationale``: on a below-floor offer the engine's rationale states the + floor price in prose ("Countering at the floor price $X CPM ..."). + + Excluding ``rationale`` here is boundary filtering on this read endpoint + only; whether the engine should put the floor in rationale text at all is + a separate, still-open decision. + """ + + round_number: int + buyer_price: float + seller_price: float + action: str + timestamp: Optional[str] = None + + +class NegotiationStatusResponse(BaseModel): + """Buyer-facing negotiation status for ``GET /proposals/{id}/negotiation``. + + Deliberately NOT a dump of the internal ``NegotiationHistory``. The + seller's ``strategy``, ``base_price``, ``floor_price`` and + ``NegotiationLimits`` (``max_rounds``) are internal guardrails and must + never cross the wire — the shared ``Negotiation`` primitive excludes all + four for exactly that reason. Pinning the wire shape here also keeps a + later addition to the service's history dict from silently re-leaking + them. + + ``rounds`` is typed (``NegotiationRoundView``) rather than passed through + as raw dicts: the internal round dumps carry ``cumulative_concession_pct`` + (which reconstructs ``base_price`` exactly) and ``rationale`` (which can + state the floor in prose). The service keeps projecting full rounds — its + internal consumer (``terminal_round_response``) needs them — and this + model drops the unsafe fields at the wire. + """ + + negotiation_id: str + proposal_id: str + # Projected by the service once quote-led negotiations record their + # quote id on the history; None until then. Declared here so the + # response model passes it through instead of silently stripping it. + quote_id: Optional[str] = None + product_id: str + buyer_tier: str + status: str + total_rounds: int + rounds: list[NegotiationRoundView] = [] + started_at: str + completed_at: Optional[str] = None + package_id: Optional[str] = None + + class QuoteBuyerIdentityModel(BaseModel): """Buyer identity in a quote request.""" diff --git a/src/ad_seller/services/negotiation_service.py b/src/ad_seller/services/negotiation_service.py index 684afa7f..32f27b4f 100644 --- a/src/ad_seller/services/negotiation_service.py +++ b/src/ad_seller/services/negotiation_service.py @@ -355,7 +355,18 @@ async def apply_terminal_action( async def get_negotiation_status(proposal_id: str) -> dict[str, Any]: - """Get full negotiation history for a proposal.""" + """Get the buyer-visible negotiation status for a proposal. + + This is a BUYER-FACING projection of the internal + ``NegotiationHistory``, not a dump of it. The history's ``strategy``, + ``base_price``, ``floor_price`` and ``limits`` (``max_rounds``) are the + seller's internal negotiation guardrails and are deliberately omitted: + a counterparty who knows the seller's floor and remaining concession + budget concedes nothing above it. The shared ``Negotiation`` primitive + excludes the same four fields for exactly this reason. + + Anything added here reaches the wire, so add buyer-visible facts only. + """ from ..models.negotiation import NegotiationHistory from ..storage.factory import get_storage @@ -370,12 +381,8 @@ async def get_negotiation_status(proposal_id: str) -> dict[str, Any]: "proposal_id": history.proposal_id, "product_id": history.product_id, "buyer_tier": history.buyer_tier.value, - "strategy": history.strategy.value, - "base_price": history.base_price, - "floor_price": history.floor_price, "status": history.status, "total_rounds": len(history.rounds), - "max_rounds": history.limits.max_rounds, "rounds": [r.model_dump(mode="json") for r in history.rounds], "started_at": history.started_at.isoformat(), "completed_at": history.completed_at.isoformat() if history.completed_at else None, diff --git a/tests/unit/test_negotiation_status_disclosure.py b/tests/unit/test_negotiation_status_disclosure.py new file mode 100644 index 00000000..95b80a16 --- /dev/null +++ b/tests/unit/test_negotiation_status_disclosure.py @@ -0,0 +1,316 @@ +# Author: Green Mountain Systems AI Inc. +# Donated to IAB Tech Lab + +"""``GET /proposals/{proposal_id}/negotiation`` must not leak the seller's +internal negotiation guardrails, and must not answer anonymous callers. + +The route previously carried no auth dependency and no ``response_model``, +returning whatever ``negotiation_service.get_negotiation_status`` produced — +including ``strategy``, ``base_price``, ``floor_price`` and +``limits.max_rounds``. Any caller who knew or guessed a proposal id learned +the seller's floor and its remaining concession budget. The shared +``Negotiation`` primitive excludes those same four fields deliberately: +they are the seller's internal guardrails and must never cross the wire. +""" + +import json +import sys +from types import ModuleType +from unittest.mock import AsyncMock, MagicMock, patch + +# Stub execution_activation_flow (cancel-scope leak on ad-server +# connection failure, unresolved -- issue #60 part 2). +_broken_flows = [ + "ad_seller.flows.execution_activation_flow", +] +for _mod_name in _broken_flows: + if _mod_name not in sys.modules: + _stub = ModuleType(_mod_name) + _cls_name = _mod_name.rsplit(".", 1)[-1].replace("_", " ").title().replace(" ", "") + setattr(_stub, _cls_name, type(_cls_name, (), {})) + sys.modules[_mod_name] = _stub + +import httpx # noqa: E402 +import pytest # noqa: E402 +from httpx import ASGITransport # noqa: E402 + +from ad_seller.interfaces.api.main import _get_optional_api_key_record, app # noqa: E402 +from ad_seller.models.buyer_identity import AccessTier, BuyerIdentity # noqa: E402 +from ad_seller.models.negotiation import ( # noqa: E402 + STRATEGY_LIMITS, + NegotiationAction, + NegotiationHistory, + NegotiationRound, + NegotiationStrategy, +) + +PROPOSAL_ID = "prop-leak-1" + +#: The seller-internal values the response must never carry. Distinctive +#: numbers so a leak is unambiguous in the serialized body. +SELLER_BASE_PRICE = 41.77 +SELLER_FLOOR_PRICE = 23.11 + + +def _stored_negotiation() -> dict: + """A persisted ``NegotiationHistory`` carrying the seller's guardrails. + + Storage legitimately holds strategy/base_price/floor_price/limits — the + engine needs them. The question under test is what reaches the wire. + """ + history = NegotiationHistory( + negotiation_id="neg-leak-1", + proposal_id=PROPOSAL_ID, + product_id="prod-ctv-1", + buyer_tier=AccessTier.AGENCY, + strategy=NegotiationStrategy.COLLABORATIVE, + limits=STRATEGY_LIMITS[NegotiationStrategy.COLLABORATIVE], + base_price=SELLER_BASE_PRICE, + floor_price=SELLER_FLOOR_PRICE, + rounds=[ + NegotiationRound( + round_number=1, + buyer_price=22.0, + seller_price=38.5, + action=NegotiationAction.COUNTER, + rationale="Counter at $38.50 CPM.", + ) + ], + ) + return history.model_dump(mode="json") + + +@pytest.fixture +def mock_storage(): + store = {f"negotiation:{PROPOSAL_ID}": _stored_negotiation()} + storage = AsyncMock() + storage.get = AsyncMock(side_effect=lambda k: store.get(k)) + storage.set = AsyncMock(side_effect=lambda k, v, ttl=None: store.__setitem__(k, v)) + storage.get_negotiation = AsyncMock(side_effect=lambda pid: store.get(f"negotiation:{pid}")) + storage.set_negotiation = AsyncMock( + side_effect=lambda pid, data: store.__setitem__(f"negotiation:{pid}", data) + ) + storage._store = store + return storage + + +@pytest.fixture +def anonymous_client(): + """No credential at all — the pre-fix caller.""" + app.dependency_overrides[_get_optional_api_key_record] = lambda: None + transport = ASGITransport(app=app) + c = httpx.AsyncClient(transport=transport, base_url="http://test") + yield c + app.dependency_overrides.clear() + + +@pytest.fixture +def authenticated_client(): + """A seller-issued buyer credential, resolved the same way the sibling + negotiation routes resolve one.""" + app.dependency_overrides[_get_optional_api_key_record] = lambda: MagicMock( + identity=BuyerIdentity(agency_id="agency-leak-1") + ) + transport = ASGITransport(app=app) + c = httpx.AsyncClient(transport=transport, base_url="http://test") + yield c + app.dependency_overrides.clear() + + +class TestNegotiationStatusDoesNotLeakGuardrails: + async def test_response_omits_seller_guardrail_fields(self, authenticated_client, mock_storage): + """strategy / base_price / floor_price / max_rounds must not appear. + + Asserted against the raw response text as well as the parsed body: + the guardrails must be absent from the payload entirely, not merely + absent from the top level. + """ + with patch("ad_seller.storage.factory.get_storage", return_value=mock_storage): + async with authenticated_client as c: + resp = await c.get(f"/proposals/{PROPOSAL_ID}/negotiation") + + assert resp.status_code == 200, resp.text + body = resp.json() + + for leaked in ("strategy", "base_price", "floor_price", "max_rounds"): + assert leaked not in body, f"{leaked} leaked in negotiation status: {body}" + assert leaked not in resp.text, f"{leaked} leaked somewhere in payload: {resp.text}" + + # The seller's actual numbers must not appear under any other key. + assert str(SELLER_FLOOR_PRICE) not in resp.text + assert str(SELLER_BASE_PRICE) not in resp.text + + # The buyer-visible facts are still served. + assert body["negotiation_id"] == "neg-leak-1" + assert body["proposal_id"] == PROPOSAL_ID + assert body["status"] == "active" + assert body["total_rounds"] == 1 + assert body["rounds"][0]["seller_price"] == 38.5 + + async def test_service_projection_omits_guardrails(self, mock_storage): + """The service projection itself drops them, so no future consumer + of ``get_negotiation_status`` can re-expose them.""" + from ad_seller.services import negotiation_service + + with patch("ad_seller.storage.factory.get_storage", return_value=mock_storage): + status = await negotiation_service.get_negotiation_status(PROPOSAL_ID) + + for leaked in ("strategy", "base_price", "floor_price", "max_rounds", "limits"): + assert leaked not in status, f"{leaked} still projected by the service: {status}" + + +class TestNegotiationStatusRequiresAuth: + async def test_anonymous_caller_is_rejected(self, anonymous_client, mock_storage): + """An unauthenticated caller who guesses a proposal id gets 401, not + another buyer's negotiation state.""" + with patch("ad_seller.storage.factory.get_storage", return_value=mock_storage): + async with anonymous_client as c: + resp = await c.get(f"/proposals/{PROPOSAL_ID}/negotiation") + + assert resp.status_code == 401, resp.text + assert "neg-leak-1" not in resp.text + assert str(SELLER_FLOOR_PRICE) not in resp.text + + async def test_authenticated_caller_is_allowed(self, authenticated_client, mock_storage): + """The gate is authentication, not a blanket denial.""" + with patch("ad_seller.storage.factory.get_storage", return_value=mock_storage): + async with authenticated_client as c: + resp = await c.get(f"/proposals/{PROPOSAL_ID}/negotiation") + + assert resp.status_code == 200, resp.text + + +# --------------------------------------------------------------------------- +# Typed rounds: engine-produced leaks must be filtered at the wire +# --------------------------------------------------------------------------- + +ENGINE_PROPOSAL_ID = "prop-engine-leak-1" +BELOW_FLOOR_OFFER = 19.0 + + +def _engine_negotiation(): + """A negotiation whose round the REAL engine produced. + + A hand-written round with the default ``cumulative_concession_pct=0.0`` + and a bland rationale cannot exhibit the round-level leaks. The engine's + below-floor branch produces both of them for real: + + - ``cumulative_concession_pct = (base_price - counter) / base_price``, + nonzero here, so ``seller_price / (1 - cumulative_concession_pct)`` + reconstructs ``base_price`` EXACTLY; + - a rationale that states the floor in prose ("Countering at the floor + price $X CPM ..."). + + Returns the stored history dump and the engine's round object. + """ + from ad_seller.services.negotiation_service import build_negotiation_engine + + engine = build_negotiation_engine() + history = engine.start_negotiation( + proposal_id=ENGINE_PROPOSAL_ID, + product_id="prod-ctv-1", + buyer_context=None, + base_price=SELLER_BASE_PRICE, + floor_price=SELLER_FLOOR_PRICE, + ) + engine_round = engine.evaluate_buyer_offer(history, BELOW_FLOOR_OFFER) + history = engine.record_round(history, engine_round) + return history.model_dump(mode="json"), engine_round + + +@pytest.fixture +def engine_mock_storage(): + stored, _ = _engine_negotiation() + store = {f"negotiation:{ENGINE_PROPOSAL_ID}": stored} + storage = AsyncMock() + storage.get = AsyncMock(side_effect=lambda k: store.get(k)) + storage.get_negotiation = AsyncMock(side_effect=lambda pid: store.get(f"negotiation:{pid}")) + return storage + + +class TestTypedRoundsBlockReconstruction: + """The rounds themselves must not undo the top-level redaction. + + ``NegotiationStatusResponse.rounds`` is typed (``NegotiationRoundView``) + precisely because raw round dumps carried ``cumulative_concession_pct`` + (an exact ``base_price`` reconstruction) and ``rationale`` (the floor in + prose). + """ + + #: The only round fields the counterparty may see. + ALLOWED_ROUND_FIELDS = {"round_number", "buyer_price", "seller_price", "action", "timestamp"} + + async def test_engine_round_leaks_do_not_reach_the_wire( + self, authenticated_client, engine_mock_storage + ): + # Sanity: the engine round really carries both leaks. Without these, + # the wire assertions below would pass vacuously (the original + # fixture's default 0.0 concession missed exactly this). + _, engine_round = _engine_negotiation() + assert engine_round.cumulative_concession_pct > 0 + reconstructed_base = engine_round.seller_price / ( + 1 - engine_round.cumulative_concession_pct + ) + assert reconstructed_base == pytest.approx(SELLER_BASE_PRICE, abs=0.01) + assert f"{SELLER_FLOOR_PRICE:.2f}" in engine_round.rationale + + with patch("ad_seller.storage.factory.get_storage", return_value=engine_mock_storage): + async with authenticated_client as c: + resp = await c.get(f"/proposals/{ENGINE_PROPOSAL_ID}/negotiation") + + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["total_rounds"] == 1 + + # Only the whitelisted round fields cross the wire. + for r in body["rounds"]: + assert set(r) == self.ALLOWED_ROUND_FIELDS, f"unexpected round fields: {sorted(r)}" + + # The forbidden field names are absent from the entire payload. + for forbidden in ("concession_pct", "cumulative_concession_pct", "rationale"): + assert forbidden not in resp.text, f"{forbidden} reached the wire: {resp.text}" + + # base_price is NOT reconstructible from the serialized payload: + # the formula needs cumulative_concession_pct alongside seller_price, + # and that input is absent (asserted just above). Nor does the base + # value itself appear anywhere. + assert str(SELLER_BASE_PRICE) not in resp.text + + # No round field names or narrates the floor. The one place the + # floor VALUE legitimately appears is seller_price: the engine + # counters every below-floor offer AT the floor, so the counter + # price numerically equals it. That is a price on the wire, not a + # disclosure — the leak was the rationale labeling it as the floor, + # and rationale is gone. + assert "floor" not in resp.text.lower() + floor_str = f"{SELLER_FLOOR_PRICE:.2f}" + for r in body["rounds"]: + assert r["seller_price"] == pytest.approx(SELLER_FLOOR_PRICE) + for key, value in r.items(): + if key == "seller_price": + continue + assert floor_str not in json.dumps(value), ( + f"floor value leaked via round field {key!r}: {value!r}" + ) + + +class TestQuoteIdPassesThrough: + def test_response_model_carries_quote_id(self): + """The service will project ``quote_id`` for quote-led negotiations; + the response model must pass it through, not silently strip it.""" + from ad_seller.interfaces.api.schemas import NegotiationStatusResponse + + status = NegotiationStatusResponse.model_validate( + { + "negotiation_id": "neg-q-1", + "proposal_id": "prop-q-1", + "quote_id": "quote-abc-1", + "product_id": "prod-ctv-1", + "buyer_tier": "agency", + "status": "active", + "total_rounds": 0, + "started_at": "2026-01-01T00:00:00", + } + ) + assert status.quote_id == "quote-abc-1" + assert status.model_dump()["quote_id"] == "quote-abc-1"