diff --git a/alembic/versions/378299ad502f_change_brand_manifest_policy_default_to_.py b/alembic/versions/378299ad502f_change_brand_manifest_policy_default_to_.py new file mode 100644 index 000000000..209a6a110 --- /dev/null +++ b/alembic/versions/378299ad502f_change_brand_manifest_policy_default_to_.py @@ -0,0 +1,50 @@ +"""change_brand_manifest_policy_default_to_require_auth + +Revision ID: 378299ad502f +Revises: 6f05f4179c33 +Create Date: 2025-10-29 02:52:27.162501 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "378299ad502f" +down_revision: str | Sequence[str] | None = "6f05f4179c33" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema - change brand_manifest_policy default to require_auth. + + This reflects the correct default for most publishers: standard B2B model + where advertisers must sign up to see products and pricing. + + Existing tenants keep their current settings (no data migration). + """ + # Change the server default for new tenants + op.alter_column( + "tenants", + "brand_manifest_policy", + server_default="require_auth", + existing_type=sa.String(50), + existing_nullable=False, + comment="Product discovery access policy: require_auth (standard B2B - signup to see pricing), require_brand (brand context required for bespoke products), public (generic products visible to all)", + ) + + +def downgrade() -> None: + """Downgrade schema - revert to require_brand default.""" + op.alter_column( + "tenants", + "brand_manifest_policy", + server_default="require_brand", + existing_type=sa.String(50), + existing_nullable=False, + comment="Brand manifest requirement policy: public (no auth, no pricing), require_auth (auth required, no brand manifest), require_brand (auth + brand manifest required)", + ) diff --git a/alembic/versions/6f05f4179c33_add_brand_manifest_policy_to_tenants.py b/alembic/versions/6f05f4179c33_add_brand_manifest_policy_to_tenants.py new file mode 100644 index 000000000..6eb5f4595 --- /dev/null +++ b/alembic/versions/6f05f4179c33_add_brand_manifest_policy_to_tenants.py @@ -0,0 +1,40 @@ +"""add_brand_manifest_policy_to_tenants + +Revision ID: 6f05f4179c33 +Revises: 319e6b366151 +Create Date: 2025-10-28 18:27:53.361639 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "6f05f4179c33" +down_revision: str | Sequence[str] | None = "319e6b366151" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # Add brand_manifest_policy column with default 'require_brand' + # This preserves existing behavior (strictest policy) for current tenants + op.add_column( + "tenants", + sa.Column( + "brand_manifest_policy", + sa.String(50), + nullable=False, + server_default="require_brand", + comment="Brand manifest requirement policy: public (no auth, no pricing), require_auth (auth required, no brand manifest), require_brand (auth + brand manifest required)", + ), + ) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_column("tenants", "brand_manifest_policy") diff --git a/docker-compose.yml b/docker-compose.yml index e716ea17d..5f6fbb1f2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -86,6 +86,7 @@ services: GAM_OAUTH_CLIENT_SECRET: ${GAM_OAUTH_CLIENT_SECRET:-} GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-} + GOOGLE_OAUTH_REDIRECT_URI: ${GOOGLE_OAUTH_REDIRECT_URI:-} APPROXIMATED_API_KEY: ${APPROXIMATED_API_KEY:-} APPROXIMATED_PROXY_IP: ${APPROXIMATED_PROXY_IP:-37.16.24.200} APPROXIMATED_BACKEND_URL: ${APPROXIMATED_BACKEND_URL:-adcp-sales-agent.fly.dev} diff --git a/schemas/v1/_schemas_v1_core_webhook-payload_json.json.meta b/schemas/v1/_schemas_v1_core_webhook-payload_json.json.meta index bd247b373..1db9d58cd 100644 --- a/schemas/v1/_schemas_v1_core_webhook-payload_json.json.meta +++ b/schemas/v1/_schemas_v1_core_webhook-payload_json.json.meta @@ -1,6 +1,7 @@ { - "etag": "W/\"68ffaa97-1ce0\"", - "last-modified": "Mon, 27 Oct 2025 17:23:35 GMT", - "downloaded_at": "2025-10-27T20:50:42.491975", - "schema_ref": "/schemas/v1/core/webhook-payload.json" + "etag": "W/\"69013dbf-1ce0\"", + "last-modified": "Tue, 28 Oct 2025 22:03:43 GMT", + "downloaded_at": "2025-10-28T18:20:43.362874", + "schema_ref": "/schemas/v1/core/webhook-payload.json", + "content_hash": "3037a3a80eb9e20dc2034a6701bf871e944f910e3ea1fec0885361ed06ace8eb" } diff --git a/schemas/v1/_schemas_v1_enums_task-type_json.json.meta b/schemas/v1/_schemas_v1_enums_task-type_json.json.meta index 091746cb1..b3906a9fe 100644 --- a/schemas/v1/_schemas_v1_enums_task-type_json.json.meta +++ b/schemas/v1/_schemas_v1_enums_task-type_json.json.meta @@ -1,6 +1,7 @@ { - "etag": "W/\"68ffaa97-531\"", - "last-modified": "Mon, 27 Oct 2025 17:23:35 GMT", - "downloaded_at": "2025-10-27T20:50:43.126772", - "schema_ref": "/schemas/v1/enums/task-type.json" + "etag": "W/\"69013dbf-531\"", + "last-modified": "Tue, 28 Oct 2025 22:03:43 GMT", + "downloaded_at": "2025-10-28T18:20:43.896965", + "schema_ref": "/schemas/v1/enums/task-type.json", + "content_hash": "7d0bd6c33ff8e507f1b2c642c00f92bbbd30f4db489f9be7fe07a10276ee604f" } diff --git a/schemas/v1/_schemas_v1_media-buy_get-products-request_json.json b/schemas/v1/_schemas_v1_media-buy_get-products-request_json.json index 27cd99883..af0391e1f 100644 --- a/schemas/v1/_schemas_v1_media-buy_get-products-request_json.json +++ b/schemas/v1/_schemas_v1_media-buy_get-products-request_json.json @@ -56,8 +56,6 @@ "additionalProperties": false } }, - "required": [ - "brand_manifest" - ], + "required": [], "additionalProperties": false } diff --git a/schemas/v1/_schemas_v1_media-buy_get-products-request_json.json.meta b/schemas/v1/_schemas_v1_media-buy_get-products-request_json.json.meta index 3a7b2e45a..ba9d28dce 100644 --- a/schemas/v1/_schemas_v1_media-buy_get-products-request_json.json.meta +++ b/schemas/v1/_schemas_v1_media-buy_get-products-request_json.json.meta @@ -1,7 +1,7 @@ { - "etag": "W/\"68ffaa97-7b9\"", - "last-modified": "Mon, 27 Oct 2025 17:23:35 GMT", - "downloaded_at": "2025-10-28T16:15:28.603429", + "etag": "W/\"69013dbf-7a1\"", + "last-modified": "Tue, 28 Oct 2025 22:03:43 GMT", + "downloaded_at": "2025-10-28T18:20:43.943318", "schema_ref": "/schemas/v1/media-buy/get-products-request.json", - "content_hash": "3de0c3f8a90fb2f1aacc80c973ccebe53737107fcea7c64abb69a33eaa3c7caf" + "content_hash": "1aee2a71dbf4212648696c52b98d69be4f9678bb12fed78b093357337e0a47c0" } diff --git a/src/admin/blueprints/policy.py b/src/admin/blueprints/policy.py index 713a62d87..1a40e035f 100644 --- a/src/admin/blueprints/policy.py +++ b/src/admin/blueprints/policy.py @@ -71,6 +71,9 @@ def index(tenant_id): policy_settings = default_policies.copy() policy_settings.update(tenant_policies) + # Get brand manifest policy + brand_manifest_policy = tenant.brand_manifest_policy + # Get recent policy checks from audit log stmt = ( select(AuditLog) @@ -124,6 +127,7 @@ def index(tenant_id): tenant_id=tenant_id, tenant_name=tenant_name, policy_settings=policy_settings, + brand_manifest_policy=brand_manifest_policy, recent_checks=recent_checks, pending_reviews=pending_reviews, ) @@ -186,11 +190,18 @@ def parse_textarea_lines(field_name): config["policy_settings"] = policy_settings + # Get brand_manifest_policy from form + brand_manifest_policy = request.form.get("brand_manifest_policy", "require_auth") + # Validate policy value + if brand_manifest_policy not in ["public", "require_auth", "require_brand"]: + brand_manifest_policy = "require_auth" # Default to standard B2B model + # Update database with get_db_session() as db_session: tenant = db_session.scalars(select(Tenant).filter_by(tenant_id=tenant_id)).first() if tenant: tenant.policy_settings = json.dumps(policy_settings) + tenant.brand_manifest_policy = brand_manifest_policy db_session.commit() return redirect(url_for("policy.index", tenant_id=tenant_id)) diff --git a/src/core/database/models.py b/src/core/database/models.py index 6a404e57c..942d2edb1 100644 --- a/src/core/database/models.py +++ b/src/core/database/models.py @@ -75,6 +75,12 @@ class Tenant(Base, JSONValidatorMixin): nullable=True, comment="Advertising policy configuration with prohibited categories, tactics, and advertisers", ) + brand_manifest_policy: Mapped[str] = mapped_column( + String(50), + nullable=False, + server_default="require_auth", + comment="Product discovery access policy: require_auth (standard B2B - signup to see pricing), require_brand (brand context required for bespoke products), public (generic products visible to all)", + ) # Naming templates (business rules - shared across all adapters) order_name_template: Mapped[str | None] = mapped_column( diff --git a/src/core/tools/products.py b/src/core/tools/products.py index 43719b395..8f543ba25 100644 --- a/src/core/tools/products.py +++ b/src/core/tools/products.py @@ -95,7 +95,45 @@ async def _get_products_impl(req: GetProductsRequestGenerated, context: Context) principal = get_principal_object(principal_id) if principal_id else None principal_data = principal.model_dump() if principal else None - # Extract offering text from brand_manifest + # Get tenant's brand manifest policy + brand_manifest_policy = tenant.get("brand_manifest_policy", "require_auth") + + # Validate brand manifest based on policy + # Policy options: + # - "public": No auth required, no brand_manifest required (but no pricing shown) + # - "require_auth": Auth required, brand_manifest optional + # - "require_brand": Auth + brand_manifest both required (strictest, default) + + if brand_manifest_policy == "require_brand": + # Strictest: require both auth and brand_manifest + if not principal_id: + raise ToolError("Authentication required. This tenant requires authentication to access product catalog.") + if not req.brand_manifest: + raise ToolError( + "brand_manifest required. This tenant requires brand information to provide product recommendations." + ) + elif brand_manifest_policy == "require_auth": + # Middle: require auth but brand_manifest is optional + if not principal_id: + raise ToolError("Authentication required. This tenant requires authentication to access product catalog.") + # brand_manifest is optional in this mode + elif brand_manifest_policy == "public": + # Most permissive: no auth required, no brand_manifest required + # Pricing will be stripped later for anonymous users + pass + else: + # Unknown policy - default to strictest for safety + logger.warning( + f"Unknown brand_manifest_policy '{brand_manifest_policy}' for tenant {tenant['tenant_id']}, defaulting to require_brand" + ) + if not principal_id: + raise ToolError("Authentication required. This tenant requires authentication to access product catalog.") + if not req.brand_manifest: + raise ToolError( + "brand_manifest required. This tenant requires brand information to provide product recommendations." + ) + + # Extract offering text from brand_manifest (if provided) offering = None if req.brand_manifest: if isinstance(req.brand_manifest, str): @@ -110,8 +148,9 @@ async def _get_products_impl(req: GetProductsRequestGenerated, context: Context) elif isinstance(req.brand_manifest, dict): offering = req.brand_manifest.get("name", "") + # If no brand_manifest provided, use a generic offering for context if not offering: - raise ToolError("brand_manifest must provide brand information") + offering = "Generic advertising campaign" # Skip strict validation in test environments (allow simple test values) @@ -490,10 +529,25 @@ async def _get_products_impl(req: GetProductsRequestGenerated, context: Context) except Exception as e: logger.warning(f"Failed to annotate pricing options with adapter support: {e}") - # Filter pricing data for anonymous users - if principal_id is None: # Anonymous user - # Remove pricing data from products for anonymous users - # Set to empty list to hide pricing (will be excluded during serialization) + # Filter pricing data based on policy and authentication + # Policy determines who can see pricing: + # - "public": No pricing for anyone (including authenticated users) + # - "require_auth": Pricing only for authenticated users + # - "require_brand": Pricing only for authenticated users with brand_manifest + + should_show_pricing = False + if brand_manifest_policy == "public": + # Public policy: never show pricing (encourages users to authenticate for pricing) + should_show_pricing = False + elif brand_manifest_policy == "require_auth": + # Require auth: show pricing only if authenticated + should_show_pricing = principal_id is not None + elif brand_manifest_policy == "require_brand": + # Require brand: show pricing only if authenticated AND brand_manifest provided + should_show_pricing = principal_id is not None and req.brand_manifest is not None + + if not should_show_pricing: + # Remove pricing data from products for product in modified_products: product.pricing_options = [] @@ -634,7 +688,9 @@ def safe_json_parse(value): # Convert pricing_options ORM objects to Pydantic objects pricing_options = [] - logger.info(f"Product {product.name} ({product.product_id}) has {len(product.pricing_options)} pricing options loaded") + logger.info( + f"Product {product.name} ({product.product_id}) has {len(product.pricing_options)} pricing options loaded" + ) for po in product.pricing_options: fixed_str = "fixed" if po.is_fixed else "auction" pricing_option_data = { diff --git a/templates/policy_settings.html b/templates/policy_settings.html index 1a1891d28..3024e709a 100644 --- a/templates/policy_settings.html +++ b/templates/policy_settings.html @@ -47,6 +47,29 @@
Policy Configuration
+
+ +
+ + + + Standard B2B (recommended): Advertisers sign up to see products and pricing. Most common model.
+ Brand context required: Advertisers provide brand info before seeing products. For highly customized/bespoke products.
+ Public marketplace: Products visible to everyone (pricing hidden). For generic, commoditized ad products.
+ Note: Future versions will support per-product settings. Currently applies to all products. +
+
+ Manage Custom Rules diff --git a/templates/policy_settings_comprehensive.html b/templates/policy_settings_comprehensive.html index b838f5f82..4d821a574 100644 --- a/templates/policy_settings_comprehensive.html +++ b/templates/policy_settings_comprehensive.html @@ -105,6 +105,24 @@
General Settings
Create review tasks for policy violations instead of auto-rejecting + +
+ +
+ + + + Authenticated buyers: Advertisers must sign up to see products and pricing.
+ Public access: Anyone can see what you offer (pricing requires signup). +
+
diff --git a/templates/tenant_settings.html b/templates/tenant_settings.html index 4e6b3e1d5..536af6af6 100644 --- a/templates/tenant_settings.html +++ b/templates/tenant_settings.html @@ -975,6 +975,26 @@

Budget Controls

+ +
+

Product Catalog Access

+
+ + + + Authenticated buyers: Advertisers must sign up to see products and pricing.
+ Public access: Anyone can see what you offer (pricing requires signup). +
+
+
+

Naming Conventions

@@ -2497,6 +2517,34 @@

Sales Agent Currently Inactive

unregisterBtn.textContent = 'Unregister Domain'; } } + +// Save Business Rules (Policies & Workflows) +async function saveBusinessRules() { + const form = document.getElementById('business-rules-form'); + const formData = new FormData(form); + + const scriptName = document.getElementById('settings-config').dataset.scriptName || ''; + const tenantId = document.getElementById('settings-config').dataset.tenantId; + + try { + const response = await fetch(`${scriptName}/tenant/${tenantId}/policy/update`, { + method: 'POST', + body: formData + }); + + if (response.ok) { + alert('✅ Business rules saved successfully!'); + // Optionally reload to show updated values + window.location.reload(); + } else { + const error = await response.text(); + alert(`❌ Error saving business rules: ${error}`); + } + } catch (error) { + console.error('Error saving business rules:', error); + alert(`❌ Error: ${error.message}`); + } +} diff --git a/tests/unit/test_brand_manifest_policy.py b/tests/unit/test_brand_manifest_policy.py new file mode 100644 index 000000000..d52885e13 --- /dev/null +++ b/tests/unit/test_brand_manifest_policy.py @@ -0,0 +1,377 @@ +"""Tests for brand manifest policy enforcement in get_products.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastmcp.exceptions import ToolError + +from src.core.tools.products import _get_products_impl + + +def mock_apply_testing_hooks(response_data, *args, **kwargs): + """Mock apply_testing_hooks that just passes through the data.""" + return response_data + + +def make_async_provider_mock(provider): + """Create an async function that returns the mock provider.""" + + async def mock_get_provider(*args, **kwargs): + return provider + + return mock_get_provider + + +class TestBrandManifestPolicy: + """Test brand manifest policy enforcement across different policy modes.""" + + @pytest.fixture + def mock_context(self): + """Create a mock FastMCP context.""" + context = MagicMock() + context.headers = {} + return context + + @pytest.fixture + def mock_tenant_require_brand(self): + """Mock tenant with require_brand policy (strictest).""" + return { + "tenant_id": "test_tenant", + "brand_manifest_policy": "require_brand", + "advertising_policy": {"enabled": False}, + } + + @pytest.fixture + def mock_tenant_require_auth(self): + """Mock tenant with require_auth policy (middle).""" + return { + "tenant_id": "test_tenant", + "brand_manifest_policy": "require_auth", + "advertising_policy": {"enabled": False}, + } + + @pytest.fixture + def mock_tenant_public(self): + """Mock tenant with public policy (most permissive).""" + return { + "tenant_id": "test_tenant", + "brand_manifest_policy": "public", + "advertising_policy": {"enabled": False}, + } + + @pytest.fixture + def mock_product_catalog_provider(self): + """Mock product catalog provider.""" + provider = AsyncMock() + provider.get_products = AsyncMock(return_value=[]) + return provider + + @pytest.mark.asyncio + async def test_require_brand_policy_rejects_unauthenticated(self, mock_context, mock_tenant_require_brand): + """Test that require_brand policy rejects unauthenticated requests.""" + request = MagicMock() + request.brief = "Test brief" + request.brand_manifest = None + request.filters = None + request.min_exposures = None # Important: prevent MagicMock from creating this + + with patch( + "src.core.tools.products.get_principal_from_context", + return_value=(None, mock_tenant_require_brand), + ): + with pytest.raises(ToolError) as exc_info: + await _get_products_impl(request, mock_context) + + assert "Authentication required" in str(exc_info.value) + + @pytest.mark.asyncio + async def test_require_brand_policy_rejects_no_brand_manifest( + self, mock_context, mock_tenant_require_brand, mock_product_catalog_provider + ): + """Test that require_brand policy rejects requests without brand_manifest.""" + request = MagicMock() + request.brief = "Test brief" + request.brand_manifest = None + request.filters = None + request.min_exposures = None # Important: prevent MagicMock from creating this + + with patch( + "src.core.tools.products.get_principal_from_context", + return_value=("principal_123", mock_tenant_require_brand), + ): + with patch("src.core.tools.products.get_principal_object", return_value=None): + with pytest.raises(ToolError) as exc_info: + await _get_products_impl(request, mock_context) + + assert "brand_manifest required" in str(exc_info.value) + + @pytest.mark.asyncio + async def test_require_auth_policy_rejects_unauthenticated(self, mock_context, mock_tenant_require_auth): + """Test that require_auth policy rejects unauthenticated requests.""" + request = MagicMock() + request.brief = "Test brief" + request.brand_manifest = None + request.filters = None + request.min_exposures = None # Important: prevent MagicMock from creating this + + with patch( + "src.core.tools.products.get_principal_from_context", + return_value=(None, mock_tenant_require_auth), + ): + with pytest.raises(ToolError) as exc_info: + await _get_products_impl(request, mock_context) + + assert "Authentication required" in str(exc_info.value) + + @pytest.mark.asyncio + async def test_require_auth_policy_allows_authenticated_no_brand_manifest( + self, mock_context, mock_tenant_require_auth, mock_product_catalog_provider + ): + """Test that require_auth policy allows authenticated requests without brand_manifest.""" + from src.core.schemas import Product + + mock_products = [ + Product( + product_id="prod_1", + name="Test Product", + description="Test", + formats=["display_300x250"], + delivery_type="guaranteed", + pricing_options=[], + property_tags=["all_inventory"], + ) + ] + + request = MagicMock() + request.brief = "Test brief" + request.brand_manifest = None + request.filters = None + request.min_exposures = None # Important: prevent MagicMock from creating this + + with ( + patch( + "src.core.tools.products.get_principal_from_context", + return_value=("principal_123", mock_tenant_require_auth), + ), + patch("src.core.tools.products.get_principal_object", return_value=None), + patch("src.core.tools.products.set_current_tenant"), + patch( + "src.core.tools.products.get_product_catalog_provider", + side_effect=make_async_provider_mock(mock_product_catalog_provider), + ), + patch("src.core.tools.products.apply_testing_hooks", side_effect=mock_apply_testing_hooks), + ): + mock_product_catalog_provider.get_products.return_value = mock_products + response = await _get_products_impl(request, mock_context) + + # Should succeed with products (no brand manifest required) + assert len(response.products) == 1 + + @pytest.mark.asyncio + async def test_public_policy_allows_unauthenticated( + self, mock_context, mock_tenant_public, mock_product_catalog_provider + ): + """Test that public policy allows unauthenticated requests.""" + from src.core.schemas import Product + + mock_products = [ + Product( + product_id="prod_1", + name="Test Product", + description="Test", + formats=["display_300x250"], + delivery_type="guaranteed", + pricing_options=[], + property_tags=["all_inventory"], + ) + ] + + request = MagicMock() + request.brief = "Test brief" + request.brand_manifest = None + request.filters = None + request.min_exposures = None # Important: prevent MagicMock from creating this + + with ( + patch( + "src.core.tools.products.get_principal_from_context", + return_value=(None, mock_tenant_public), + ), + patch("src.core.tools.products.get_principal_object", return_value=None), + patch( + "src.core.tools.products.get_product_catalog_provider", + return_value=mock_product_catalog_provider, + ), + patch("src.core.tools.products.apply_testing_hooks", side_effect=mock_apply_testing_hooks), + ): + mock_product_catalog_provider.get_products.return_value = mock_products + response = await _get_products_impl(request, mock_context) + + # Should succeed with products (public policy) + assert len(response.products) == 1 + # Pricing should be hidden for unauthenticated user + assert response.products[0].pricing_options == [] + + @pytest.mark.asyncio + async def test_public_policy_hides_pricing_even_for_authenticated( + self, mock_context, mock_tenant_public, mock_product_catalog_provider + ): + """Test that public policy hides pricing even for authenticated users.""" + from src.core.schemas import PricingOption, Product + + mock_products = [ + Product( + product_id="prod_1", + name="Test Product", + description="Test", + formats=["display_300x250"], + delivery_type="guaranteed", + pricing_options=[ + PricingOption( + pricing_option_id="cpm_usd_fixed", + pricing_model="cpm", + rate=5.0, + currency="USD", + is_fixed=True, + ) + ], + property_tags=["all_inventory"], + ) + ] + + request = MagicMock() + request.brief = "Test brief" + request.brand_manifest = {"name": "Test Brand"} + request.filters = None + request.min_exposures = None # Important: prevent MagicMock from creating this + + with ( + patch( + "src.core.tools.products.get_principal_from_context", + return_value=("principal_123", mock_tenant_public), + ), + patch("src.core.tools.products.get_principal_object", return_value=None), + patch("src.core.tools.products.set_current_tenant"), + patch( + "src.core.tools.products.get_product_catalog_provider", + side_effect=make_async_provider_mock(mock_product_catalog_provider), + ), + patch("src.core.tools.products.apply_testing_hooks", side_effect=mock_apply_testing_hooks), + ): + mock_product_catalog_provider.get_products.return_value = mock_products + response = await _get_products_impl(request, mock_context) + + # Should succeed with products + assert len(response.products) == 1 + # Pricing should be hidden even for authenticated user (public policy) + assert response.products[0].pricing_options == [] + + @pytest.mark.asyncio + async def test_require_auth_policy_shows_pricing_for_authenticated( + self, mock_context, mock_tenant_require_auth, mock_product_catalog_provider + ): + """Test that require_auth policy shows pricing for authenticated users.""" + from src.core.schemas import PricingOption, Product + + mock_products = [ + Product( + product_id="prod_1", + name="Test Product", + description="Test", + formats=["display_300x250"], + delivery_type="guaranteed", + pricing_options=[ + PricingOption( + pricing_option_id="cpm_usd_fixed", + pricing_model="cpm", + rate=5.0, + currency="USD", + is_fixed=True, + ) + ], + property_tags=["all_inventory"], + ) + ] + + request = MagicMock() + request.brief = "Test brief" + request.brand_manifest = None + request.filters = None + request.min_exposures = None # Important: prevent MagicMock from creating this + + with ( + patch( + "src.core.tools.products.get_principal_from_context", + return_value=("principal_123", mock_tenant_require_auth), + ), + patch("src.core.tools.products.get_principal_object", return_value=None), + patch("src.core.tools.products.set_current_tenant"), + patch( + "src.core.tools.products.get_product_catalog_provider", + return_value=mock_product_catalog_provider, + ), + patch("src.core.tools.products.apply_testing_hooks", side_effect=mock_apply_testing_hooks), + ): + mock_product_catalog_provider.get_products.return_value = mock_products + response = await _get_products_impl(request, mock_context) + + # Should succeed with products + assert len(response.products) == 1 + # Pricing should be shown for authenticated user + assert len(response.products[0].pricing_options) == 1 + assert response.products[0].pricing_options[0].rate == 5.0 + + @pytest.mark.asyncio + async def test_require_brand_policy_shows_pricing_with_brand_manifest( + self, mock_context, mock_tenant_require_brand, mock_product_catalog_provider + ): + """Test that require_brand policy shows pricing when both auth and brand_manifest provided.""" + from src.core.schemas import PricingOption, Product + + mock_products = [ + Product( + product_id="prod_1", + name="Test Product", + description="Test", + formats=["display_300x250"], + delivery_type="guaranteed", + pricing_options=[ + PricingOption( + pricing_option_id="cpm_usd_fixed", + pricing_model="cpm", + rate=5.0, + currency="USD", + is_fixed=True, + ) + ], + property_tags=["all_inventory"], + ) + ] + + request = MagicMock() + request.brief = "Test brief" + request.brand_manifest = {"name": "Test Brand"} + request.filters = None + request.min_exposures = None # Important: prevent MagicMock from creating this + + with ( + patch( + "src.core.tools.products.get_principal_from_context", + return_value=("principal_123", mock_tenant_require_brand), + ), + patch("src.core.tools.products.get_principal_object", return_value=None), + patch("src.core.tools.products.set_current_tenant"), + patch( + "src.core.tools.products.get_product_catalog_provider", + side_effect=make_async_provider_mock(mock_product_catalog_provider), + ), + patch("src.core.tools.products.apply_testing_hooks", side_effect=mock_apply_testing_hooks), + ): + mock_product_catalog_provider.get_products.return_value = mock_products + response = await _get_products_impl(request, mock_context) + + # Should succeed with products + assert len(response.products) == 1 + # Pricing should be shown (auth + brand manifest provided) + assert len(response.products[0].pricing_options) == 1 + assert response.products[0].pricing_options[0].rate == 5.0