diff --git a/CHANGELOG.md b/CHANGELOG.md index be123d9d..61b7007e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # next version - Serve `robots.txt` again on Shopware `6.7.1.0` to `6.7.4.x` instead of answering `500`. +- Apply a sales channel's own UCP settings to requests under a domain with a path, such as `https://shop.example/de`. These requests used the UCP settings of the host's root domain. Under the `strict` signature policy, correctly signed requests to such a domain are no longer refused. Agents only discover a UCP profile at `/.well-known/ucp` on the root of a domain, so a sales channel that agents should find needs a domain without a path. A domain with a path no longer serves a UCP profile, and its storefront pages and discovery files no longer link one. Until now `/de/.well-known/ucp` served the profile of the host's root domain. `ucp:config:validate` now warns about a UCP sales channel whose domains all have a path. # 1.4.0 diff --git a/CHANGELOG_de-DE.md b/CHANGELOG_de-DE.md index f2f4aa52..476cb8ec 100644 --- a/CHANGELOG_de-DE.md +++ b/CHANGELOG_de-DE.md @@ -1,6 +1,7 @@ # next version - Unter Shopware `6.7.1.0` bis `6.7.4.x` liefert der Shop `robots.txt` wieder aus, statt mit `500` zu antworten. +- Für Requests unter einer Domain mit Pfad, etwa `https://shop.example/de`, gelten jetzt die UCP-Einstellungen des Verkaufskanals, dem diese Domain gehört. Bisher galten für sie die UCP-Einstellungen der Stammdomain des Hosts. Gilt die Signaturrichtlinie `strict`, werden korrekt signierte Requests an eine solche Domain nicht mehr abgelehnt. Agenten suchen ein UCP-Profil nur unter `/.well-known/ucp` im Wurzelverzeichnis einer Domain, deshalb braucht ein Verkaufskanal, den Agenten finden sollen, eine Domain ohne Pfad. Eine Domain mit Pfad liefert kein UCP-Profil mehr aus, und ihre Storefront-Seiten sowie ihre Discovery-Dateien verweisen auf keines mehr. Bisher lieferte `/de/.well-known/ucp` das Profil der Stammdomain des Hosts aus. `ucp:config:validate` warnt jetzt, wenn alle Domains eines für UCP freigegebenen Verkaufskanals einen Pfad enthalten. # 1.4.0 diff --git a/README.md b/README.md index 9383c678..1c5521dd 100644 --- a/README.md +++ b/README.md @@ -62,8 +62,9 @@ the `ucp-php-sdk`. ### Set up UCP on a sales channel The whole path from "plugin activated" to "first UCP request answered", for one sales channel. You -need a Storefront or Headless sales channel with at least one domain; every other channel type is -refused. +need a Storefront or Headless sales channel; every other channel type is refused. Agents find the +channel only through a domain without a path, such as `https://de.shop.example`, because a domain +like `https://shop.example/de` serves no UCP profile. 1. **Configure the channel in one step.** diff --git a/src/AgenticFiles/ApiCatalog/ApiCatalogLinksetBuilder.php b/src/AgenticFiles/ApiCatalog/ApiCatalogLinksetBuilder.php index a98d998f..8662c0df 100644 --- a/src/AgenticFiles/ApiCatalog/ApiCatalogLinksetBuilder.php +++ b/src/AgenticFiles/ApiCatalog/ApiCatalogLinksetBuilder.php @@ -6,6 +6,7 @@ use Shopware\Core\Framework\Api\ApiDefinition\DefinitionService; use Shopware\Core\Framework\Log\Package; +use Swag\AgenticCommerce\Ucp\Profile\UcpProfileLocator; /** * Builds the RFC 9727 `/.well-known/api-catalog` document as an RFC 9264 linkset, @@ -23,7 +24,10 @@ final class ApiCatalogLinksetBuilder public const API_CATALOG_PATH = '/.well-known/api-catalog'; - private const UCP_PROFILE_PATH = '/.well-known/ucp'; + public function __construct( + private readonly UcpProfileLocator $profileLocator, + ) { + } /** * @return array{linkset: list>} @@ -31,28 +35,29 @@ final class ApiCatalogLinksetBuilder public function build(string $baseUrl): array { // An empty base URL yields valid root-relative URI references in the linkset. - return [ - 'linkset' => [ + $apiCatalogAnchorEntry = ['anchor' => $baseUrl.self::API_CATALOG_PATH]; + + $ucpProfileUrl = $this->profileLocator->profileUrl($baseUrl); + if (null !== $ucpProfileUrl) { + // The UCP profile is the shop's machine-readable API description; it is linked + // with the same `service-meta` relation the storefront already advertises via + // the UCP profile Link header. + $apiCatalogAnchorEntry['service-meta'] = [ [ - 'anchor' => $baseUrl.self::API_CATALOG_PATH, - // The UCP profile is the shop's machine-readable API description; it is linked - // with the same `service-meta` relation the storefront already advertises via - // the UCP profile Link header. - 'service-meta' => [ - [ - 'href' => $baseUrl.self::UCP_PROFILE_PATH, - 'type' => 'application/json', - ], - ], - // The Store API entry point is a member API of the catalog (RFC 9727 `item`). - 'item' => [ - [ - 'href' => $baseUrl.'/'.DefinitionService::STORE_API, - 'type' => 'application/json', - ], - ], + 'href' => $ucpProfileUrl, + 'type' => 'application/json', ], + ]; + } + + // The Store API entry point is a member API of the catalog (RFC 9727 `item`). + $apiCatalogAnchorEntry['item'] = [ + [ + 'href' => $baseUrl.'/'.DefinitionService::STORE_API, + 'type' => 'application/json', ], ]; + + return ['linkset' => [$apiCatalogAnchorEntry]]; } } diff --git a/src/AgenticFiles/Fallback/Resources/views/files/agentic/.well-known/ai-catalog.json.twig b/src/AgenticFiles/Fallback/Resources/views/files/agentic/.well-known/ai-catalog.json.twig index 8d1e99ab..591300d3 100644 --- a/src/AgenticFiles/Fallback/Resources/views/files/agentic/.well-known/ai-catalog.json.twig +++ b/src/AgenticFiles/Fallback/Resources/views/files/agentic/.well-known/ai-catalog.json.twig @@ -7,12 +7,12 @@ {% block agentic_ai_catalog_entries %} {# Keep this inline: Twig includes cannot mutate the caller's entries array, and this fallback owns JSON rendering. Keep in sync with the core override. #} - {% if swag_agentic_commerce_ucp_active(salesChannel) and salesChannelFileContext.publisher|default(null) and salesChannelFileContext.baseUrl|default(null) %} + {% if swag_agentic_commerce_ucp_active(salesChannel) and salesChannelFileContext.publisher|default(null) and salesChannelFileContext.baseUrl|default(null) and swag_agentic_commerce_ucp_profile_url(salesChannelFileContext.baseUrl) %} {% set entries = entries|merge([{ identifier: 'urn:air:' ~ salesChannelFileContext.publisher ~ ':resource:ucp-profile', displayName: 'Shopware UCP Profile', type: 'application/json', - url: salesChannelFileContext.baseUrl ~ '/.well-known/ucp', + url: swag_agentic_commerce_ucp_profile_url(salesChannelFileContext.baseUrl), description: 'Machine-readable Universal Commerce Protocol profile for enabled commerce capabilities on this sales channel.', tags: ['shopware', 'ucp', 'commerce'], representativeQueries: [ diff --git a/src/Resources/config/services.php b/src/Resources/config/services.php index d514fd3a..a89213d9 100644 --- a/src/Resources/config/services.php +++ b/src/Resources/config/services.php @@ -127,6 +127,8 @@ use Swag\AgenticCommerce\Ucp\Gateway\ShopwareDataMapperInterface; use Swag\AgenticCommerce\Ucp\Gateway\ShopwareOrderGateway; use Swag\AgenticCommerce\Ucp\Http\ConfiguredUrlSafetyValidatorFactory; +use Swag\AgenticCommerce\Ucp\Http\PathPrefixRestoringRequestContextFactory; +use Swag\AgenticCommerce\Ucp\Http\PathPrefixRestoringResponseSignatureService; use Swag\AgenticCommerce\Ucp\Identity\CleanupExpiredOAuthTokensTask; use Swag\AgenticCommerce\Ucp\Identity\CleanupExpiredOAuthTokensTaskHandler; use Swag\AgenticCommerce\Ucp\Identity\ShopwareIdentityLinkingAdapter; @@ -175,6 +177,8 @@ use Ucp\Sdk\Contract\IdentityLinkingCapabilityInterface; use Ucp\Sdk\Contract\OrderCapabilityInterface; use Ucp\Sdk\Contract\TokenizationCapabilityInterface; +use Ucp\Sdk\Service\HttpRequestContextFactoryInterface; +use Ucp\Sdk\Service\ResponseSignatureServiceInterface; use Ucp\Sdk\Service\RuntimeConfigurationResolverInterface; use Ucp\Sdk\Symfony\Bridge\EmbeddedPageRendererInterface; @@ -537,6 +541,12 @@ $services->set(ShopwareRuntimeConfigurationResolver::class) ->arg('$profileFetchingDevelopmentMode', env('bool:default:defaults_bool_false:SWAG_AGENTIC_COMMERCE_UCP_PROFILE_FETCHING_DEVELOPMENT_MODE')); $services->alias(RuntimeConfigurationResolverInterface::class, ShopwareRuntimeConfigurationResolver::class); + $services->set(PathPrefixRestoringRequestContextFactory::class) + ->decorate(HttpRequestContextFactoryInterface::class) + ->arg('$inner', service('.inner')); + $services->set(PathPrefixRestoringResponseSignatureService::class) + ->decorate(ResponseSignatureServiceInterface::class) + ->arg('$inner', service('.inner')); // Fetched from the container by the plugin's activate()/update() hooks. $services->alias(AgenticFilesCoreBridgeInterface::class, CoreSalesChannelFileBridge::class)->public(); diff --git a/src/Resources/views/agentic-commerce/includes/ucp-agents.md.twig b/src/Resources/views/agentic-commerce/includes/ucp-agents.md.twig index e87a5cdc..d87ec305 100644 --- a/src/Resources/views/agentic-commerce/includes/ucp-agents.md.twig +++ b/src/Resources/views/agentic-commerce/includes/ucp-agents.md.twig @@ -1,4 +1,4 @@ -{% if swag_agentic_commerce_ucp_active(salesChannel) %} +{% if swag_agentic_commerce_ucp_active(salesChannel) and swag_agentic_commerce_ucp_profile_url(salesChannelFileContext.baseUrl|default(null)) %} ## Agentic commerce via UCP - Check `/.well-known/ucp` before using UCP. Only use capabilities and transports advertised for this sales channel. diff --git a/src/Resources/views/agentic-commerce/includes/ucp-llms.txt.twig b/src/Resources/views/agentic-commerce/includes/ucp-llms.txt.twig index 21387f2e..03a1275b 100644 --- a/src/Resources/views/agentic-commerce/includes/ucp-llms.txt.twig +++ b/src/Resources/views/agentic-commerce/includes/ucp-llms.txt.twig @@ -1,4 +1,4 @@ -{% if swag_agentic_commerce_ucp_active(salesChannel) %} +{% if swag_agentic_commerce_ucp_active(salesChannel) and swag_agentic_commerce_ucp_profile_url(salesChannelFileContext.baseUrl|default(null)) %} ## Agentic commerce - [UCP profile](/.well-known/ucp): Machine-readable Universal Commerce Protocol profile for this sales channel. diff --git a/src/Resources/views/files/agentic/.well-known/ai-catalog.json.twig b/src/Resources/views/files/agentic/.well-known/ai-catalog.json.twig index e3bc1026..87664ddb 100644 --- a/src/Resources/views/files/agentic/.well-known/ai-catalog.json.twig +++ b/src/Resources/views/files/agentic/.well-known/ai-catalog.json.twig @@ -2,12 +2,12 @@ {% block agentic_ai_catalog_entries %} {# Keep this inline: this override must merge before parent() so core entries stay intact. Keep in sync with the fallback template. #} - {% if swag_agentic_commerce_ucp_active(salesChannel) and salesChannelFileContext.publisher|default(null) and salesChannelFileContext.baseUrl|default(null) %} + {% if swag_agentic_commerce_ucp_active(salesChannel) and salesChannelFileContext.publisher|default(null) and salesChannelFileContext.baseUrl|default(null) and swag_agentic_commerce_ucp_profile_url(salesChannelFileContext.baseUrl) %} {% set entries = entries|merge([{ identifier: 'urn:air:' ~ salesChannelFileContext.publisher ~ ':resource:ucp-profile', displayName: 'Shopware UCP Profile', type: 'application/json', - url: salesChannelFileContext.baseUrl ~ '/.well-known/ucp', + url: swag_agentic_commerce_ucp_profile_url(salesChannelFileContext.baseUrl), description: 'Machine-readable Universal Commerce Protocol profile for enabled commerce capabilities on this sales channel.', tags: ['shopware', 'ucp', 'commerce'], representativeQueries: [ diff --git a/src/Storefront/Subscriber/UcpProfileLinkHeaderSubscriber.php b/src/Storefront/Subscriber/UcpProfileLinkHeaderSubscriber.php index 73496059..cf34d489 100644 --- a/src/Storefront/Subscriber/UcpProfileLinkHeaderSubscriber.php +++ b/src/Storefront/Subscriber/UcpProfileLinkHeaderSubscriber.php @@ -7,6 +7,7 @@ use Shopware\Core\Framework\Event\BeforeSendResponseEvent; use Shopware\Core\Framework\Log\Package; use Swag\AgenticCommerce\Ucp\Config\UcpConfigService; +use Swag\AgenticCommerce\Ucp\Profile\UcpProfileLocator; use Swag\AgenticCommerce\Ucp\SalesChannel\SalesChannelDomainResolver; use Symfony\Component\EventDispatcher\EventSubscriberInterface; @@ -19,6 +20,7 @@ final class UcpProfileLinkHeaderSubscriber implements EventSubscriberInterface public function __construct( private readonly UcpConfigService $configService, private readonly SalesChannelDomainResolver $domainResolver, + private readonly UcpProfileLocator $profileLocator, ) { } @@ -36,10 +38,11 @@ public function onBeforeSendResponse(BeforeSendResponseEvent $event): void return; } - $salesChannelResolution = $this->domainResolver->resolveByAbsoluteUri($event->getRequest()->getUri()); + $resolvedDomain = $this->domainResolver->resolveByRequest($event->getRequest()); if ( - null === $salesChannelResolution - || !$this->isUcpActiveForSalesChannel($salesChannelResolution->salesChannelId) + null === $resolvedDomain + || null === $this->profileLocator->profileUrl($resolvedDomain->baseUrl) + || !$this->isUcpActiveForSalesChannel($resolvedDomain->salesChannelId) ) { return; } diff --git a/src/Ucp/Command/UcpSetupCommand.php b/src/Ucp/Command/UcpSetupCommand.php index 62fbb613..ab26aa2b 100644 --- a/src/Ucp/Command/UcpSetupCommand.php +++ b/src/Ucp/Command/UcpSetupCommand.php @@ -15,6 +15,7 @@ use Swag\AgenticCommerce\Ucp\Config\Validation\Finding; use Swag\AgenticCommerce\Ucp\Config\Validation\Severity; use Swag\AgenticCommerce\Ucp\Config\Validation\UcpConfigValidator; +use Swag\AgenticCommerce\Ucp\Profile\UcpProfileLocator; use Swag\AgenticCommerce\Ucp\SalesChannel\SalesChannelDomainView; use Swag\AgenticCommerce\Ucp\SalesChannel\SalesChannelView; use Swag\AgenticCommerce\Ucp\SalesChannel\SalesChannelViewProvider; @@ -61,6 +62,7 @@ public function __construct( private readonly UcpConfigService $configService, private readonly UcpSigningKeyService $signingKeyService, private readonly UcpConfigValidator $validator, + private readonly UcpProfileLocator $profileLocator, ) { parent::__construct(); } @@ -274,12 +276,17 @@ private function renderFindings(SymfonyStyle $io, array $findings): void private function renderSummary(SymfonyStyle $io, UcpConfig $config, SalesChannelView $channel): void { $io->section('Result'); - $profileUrls = array_map( - static fn (SalesChannelDomainView $domain): string => rtrim($domain->url, '/').'/.well-known/ucp', + $profileUrls = array_values(array_filter(array_map( + fn (SalesChannelDomainView $domain): ?string => $this->profileLocator->profileUrl($domain->url), $channel->domains, - ); + ))); + $profileUrlSummary = match (true) { + [] === $channel->domains => '(no domain on this sales channel yet)', + [] === $profileUrls => '(none: every domain of this sales channel has a path)', + default => implode("\n", $profileUrls), + }; $io->definitionList( - ['Profile URL' => [] === $profileUrls ? '(no domain on this sales channel yet)' : implode("\n", $profileUrls)], + ['Profile URL' => $profileUrlSummary], ['Signature policy' => $config->signaturePolicy], ['Idempotency required' => $config->idempotencyRequired ? 'yes' : 'no'], ['Platform allowlist' => [] === $config->platformAllowlist ? '(none: no platform can talk to this channel yet)' : implode(', ', $config->platformAllowlist)], diff --git a/src/Ucp/Config/ShopwareRuntimeConfigurationResolver.php b/src/Ucp/Config/ShopwareRuntimeConfigurationResolver.php index 1528e98b..9e43c388 100644 --- a/src/Ucp/Config/ShopwareRuntimeConfigurationResolver.php +++ b/src/Ucp/Config/ShopwareRuntimeConfigurationResolver.php @@ -6,6 +6,7 @@ use Shopware\Core\Framework\Log\Package; use Swag\AgenticCommerce\Compatibility\ShopwareVersionDetector; +use Swag\AgenticCommerce\Ucp\Http\PathPrefixRestorer; use Swag\AgenticCommerce\Ucp\SalesChannel\SalesChannelDomainResolver; use Ucp\Sdk\Model\Config\RuntimeConfiguration; use Ucp\Sdk\Model\Http\HttpRequest; @@ -19,13 +20,14 @@ public function __construct( private readonly UcpConfigService $configService, private readonly SalesChannelDomainResolver $domainResolver, private readonly ShopwareVersionDetector $versionDetector, + private readonly PathPrefixRestorer $pathPrefixRestorer, private readonly bool $profileFetchingDevelopmentMode = false, ) { } public function resolve(HttpRequest $request): RuntimeConfiguration { - $resolution = $this->domainResolver->resolveByAbsoluteUri($request->absoluteUri); + $resolution = $this->domainResolver->resolveByAbsoluteUri($this->pathPrefixRestorer->restore($request->absoluteUri)); $config = $this->configService->getConfig($resolution?->salesChannelId); $baseUri = $this->fallbackBaseUri($request->absoluteUri); if (null !== $resolution) { diff --git a/src/Ucp/Config/Validation/UcpConfigValidator.php b/src/Ucp/Config/Validation/UcpConfigValidator.php index cbeb84a6..fd5d459d 100644 --- a/src/Ucp/Config/Validation/UcpConfigValidator.php +++ b/src/Ucp/Config/Validation/UcpConfigValidator.php @@ -65,6 +65,7 @@ public function validate( $this->checkSigningKeys($signingKeys, $salesChannelId, $now, $add); $this->checkExposureReadiness($config, $add); $this->checkProfileDomain($config, $domains, $add); + $this->checkPathPrefixedDomains($domains, $add); $this->checkIdempotency($config, $salesChannelId, $add); $this->checkEmbedded($config, $salesChannelId, $add); @@ -199,6 +200,40 @@ private function checkProfileDomain(UcpConfig $config, array $domains, callable ); } + /** + * @param array $domains + */ + private function checkPathPrefixedDomains(array $domains, callable $add): void + { + $pathPrefixedUrls = []; + foreach ($domains as $domain) { + $url = \is_array($domain) ? ($domain['url'] ?? null) : null; + if (!\is_string($url)) { + continue; + } + + if ('' === rtrim((string) parse_url($url, \PHP_URL_PATH), '/')) { + return; + } + + $pathPrefixedUrls[] = $url; + } + + if ([] === $pathPrefixedUrls) { + return; + } + + $add( + Severity::Warning, + 'path_prefixed_domain', + \sprintf( + 'Every storefront domain of this channel has a path (%s). UCP requires a business profile at /.well-known/ucp on the root of its origin (RFC 8615, section 3: https://www.rfc-editor.org/rfc/rfc8615.html#section-3), so agents cannot discover this channel and a conformant platform rejects its signed webhooks.', + implode(', ', $pathPrefixedUrls), + ), + 'Add a domain without a path to the sales channel in the Administration, for example a subdomain.', + ); + } + private function checkIdempotency(UcpConfig $config, string $salesChannelId, callable $add): void { if (!$config->idempotencyRequired) { diff --git a/src/Ucp/Embedded/EmbeddedResponseListener.php b/src/Ucp/Embedded/EmbeddedResponseListener.php index a6212d24..6e6c7166 100644 --- a/src/Ucp/Embedded/EmbeddedResponseListener.php +++ b/src/Ucp/Embedded/EmbeddedResponseListener.php @@ -31,7 +31,7 @@ public function onKernelRequest(RequestEvent $event): void return; } - $config = $this->configService->getConfig($this->domainResolver->resolveByAbsoluteUri($request->getUri())?->salesChannelId); + $config = $this->configService->getConfig($this->domainResolver->resolveByRequest($request)?->salesChannelId); $origin = $request->headers->get('origin'); if ([] === $config->embeddedAllowedOrigins) { @@ -67,7 +67,7 @@ public function onKernelResponse(ResponseEvent $event): void return; } - $config = $this->configService->getConfig($this->domainResolver->resolveByAbsoluteUri($request->getUri())?->salesChannelId); + $config = $this->configService->getConfig($this->domainResolver->resolveByRequest($request)?->salesChannelId); $frameAncestors = [] !== $config->embeddedFrameAncestors ? $config->embeddedFrameAncestors : ["'self'"]; $response = $event->getResponse(); $origin = $request->headers->get('origin'); diff --git a/src/Ucp/Http/PathPrefixRestorer.php b/src/Ucp/Http/PathPrefixRestorer.php new file mode 100644 index 00000000..a94b8020 --- /dev/null +++ b/src/Ucp/Http/PathPrefixRestorer.php @@ -0,0 +1,36 @@ +requestStack->getCurrentRequest(), $this->requestStack->getMainRequest()] as $candidateRequest) { + if (null !== $candidateRequest && $candidateRequest->getUri() === $absoluteUri) { + return SalesChannelDomainResolver::absoluteRequestUri($candidateRequest); + } + } + + return $absoluteUri; + } +} diff --git a/src/Ucp/Http/PathPrefixRestoringRequestContextFactory.php b/src/Ucp/Http/PathPrefixRestoringRequestContextFactory.php new file mode 100644 index 00000000..70d476a9 --- /dev/null +++ b/src/Ucp/Http/PathPrefixRestoringRequestContextFactory.php @@ -0,0 +1,32 @@ +inner->create(new HttpRequest( + $request->method, + $this->pathPrefixRestorer->restore($request->absoluteUri), + $request->headers, + $request->query, + $request->body, + )); + } +} diff --git a/src/Ucp/Http/PathPrefixRestoringResponseSignatureService.php b/src/Ucp/Http/PathPrefixRestoringResponseSignatureService.php new file mode 100644 index 00000000..8863bb13 --- /dev/null +++ b/src/Ucp/Http/PathPrefixRestoringResponseSignatureService.php @@ -0,0 +1,44 @@ +inner->sign( + $response, + new HttpRequest( + $request->method, + $this->pathPrefixRestorer->restore($request->absoluteUri), + $request->headers, + $request->query, + $request->body, + ), + $key, + $created, + $expires, + ); + } +} diff --git a/src/Ucp/Mcp/Api/UcpMcpProxyController.php b/src/Ucp/Mcp/Api/UcpMcpProxyController.php index 8954c391..2f6d8d0a 100644 --- a/src/Ucp/Mcp/Api/UcpMcpProxyController.php +++ b/src/Ucp/Mcp/Api/UcpMcpProxyController.php @@ -157,7 +157,7 @@ private function pinUcpToolset(Request $request): void */ private function resolveSalesChannel(Request $request): ?array { - $resolution = $this->domainResolver->resolveByAbsoluteUri($request->getUri()); + $resolution = $this->domainResolver->resolveByRequest($request); if (null === $resolution) { return null; } diff --git a/src/Ucp/Profile/PathPrefixedProfileNotFoundListener.php b/src/Ucp/Profile/PathPrefixedProfileNotFoundListener.php new file mode 100644 index 00000000..30974319 --- /dev/null +++ b/src/Ucp/Profile/PathPrefixedProfileNotFoundListener.php @@ -0,0 +1,45 @@ + 'onRequest', + ]; + } + + public function onRequest(RequestEvent $event): void + { + $request = $event->getRequest(); + if (!$event->isMainRequest() || UcpProfileLocator::PROFILE_PATH !== $request->getPathInfo()) { + return; + } + + $requestDomain = $this->domainResolver->resolveByRequest($request); + if (null === $requestDomain || null !== $this->profileLocator->profileUrl($requestDomain->baseUrl)) { + return; + } + + throw new NotFoundHttpException(); + } +} diff --git a/src/Ucp/Profile/ProfileCacheHeadersListener.php b/src/Ucp/Profile/ProfileCacheHeadersListener.php index 33827274..d3fced56 100644 --- a/src/Ucp/Profile/ProfileCacheHeadersListener.php +++ b/src/Ucp/Profile/ProfileCacheHeadersListener.php @@ -38,8 +38,6 @@ #[Package('framework')] final class ProfileCacheHeadersListener implements EventSubscriberInterface { - private const PROFILE_PATH = '/.well-known/ucp'; - private const MAX_AGE_SECONDS = 60; public static function getSubscribedEvents(): array @@ -55,7 +53,7 @@ public static function getSubscribedEvents(): array public function onResponse(ResponseEvent $event): void { - if (!$event->isMainRequest() || self::PROFILE_PATH !== $event->getRequest()->getPathInfo()) { + if (!$event->isMainRequest() || UcpProfileLocator::PROFILE_PATH !== $event->getRequest()->getPathInfo()) { return; } @@ -70,7 +68,7 @@ public function onResponse(ResponseEvent $event): void public function onBeforeSendResponse(BeforeSendResponseEvent $event): void { - if (self::PROFILE_PATH !== $event->getRequest()->getPathInfo()) { + if (UcpProfileLocator::PROFILE_PATH !== $event->getRequest()->getPathInfo()) { return; } diff --git a/src/Ucp/Profile/UcpProfileLocator.php b/src/Ucp/Profile/UcpProfileLocator.php new file mode 100644 index 00000000..fc903622 --- /dev/null +++ b/src/Ucp/Profile/UcpProfileLocator.php @@ -0,0 +1,31 @@ +resolveByAbsoluteUri($baseUri, $context); } + public function resolveByRequest(Request $request, ?Context $context = null): ?SalesChannelResolution + { + return $this->resolveByAbsoluteUri(self::absoluteRequestUri($request), $context); + } + + /** + * Core's RequestTransformer strips a domain's path prefix from the request URI, so the URI the + * request reports would match `https://shop.example/de` to the host's root domain. + */ + public static function absoluteRequestUri(Request $request): string + { + $originalRequestUri = $request->attributes->get(RequestTransformer::ORIGINAL_REQUEST_URI); + if (!\is_string($originalRequestUri) || '' === $originalRequestUri) { + return $request->getUri(); + } + + return $request->getSchemeAndHttpHost().$originalRequestUri; + } + public function resolveByHostAndPath(string $host, string $path = '/', ?Context $context = null): ?SalesChannelResolution { $absoluteUri = 'http://'.$host.('' !== $path && '/' === $path[0] ? $path : '/'.$path); diff --git a/src/Ucp/Twig/UcpAgenticFilesExtension.php b/src/Ucp/Twig/UcpAgenticFilesExtension.php index 5150f7d6..a34583f4 100644 --- a/src/Ucp/Twig/UcpAgenticFilesExtension.php +++ b/src/Ucp/Twig/UcpAgenticFilesExtension.php @@ -7,6 +7,9 @@ use Shopware\Core\Framework\Log\Package; use Shopware\Core\System\SalesChannel\SalesChannelEntity; use Swag\AgenticCommerce\Ucp\Config\UcpConfigService; +use Swag\AgenticCommerce\Ucp\Profile\UcpProfileLocator; +use Swag\AgenticCommerce\Ucp\SalesChannel\SalesChannelDomainResolver; +use Symfony\Component\HttpFoundation\RequestStack; use Twig\Extension\AbstractExtension; use Twig\TwigFunction; @@ -14,17 +17,31 @@ #[Package('discovery')] final class UcpAgenticFilesExtension extends AbstractExtension { - public function __construct(private readonly UcpConfigService $configService) - { + public function __construct( + private readonly UcpConfigService $configService, + private readonly UcpProfileLocator $profileLocator, + private readonly SalesChannelDomainResolver $domainResolver, + private readonly RequestStack $requestStack, + ) { } public function getFunctions(): array { return [ new TwigFunction('swag_agentic_commerce_ucp_active', $this->isUcpActive(...)), + new TwigFunction('swag_agentic_commerce_ucp_profile_url', $this->ucpProfileUrl(...)), ]; } + /** + * Core passes a base URL only to `ai-catalog.json`. Other files fall back to the requested + * domain, and to the root-relative path when no domain matches, as in an Administration preview. + */ + public function ucpProfileUrl(?string $baseUrl = null): ?string + { + return $this->profileLocator->profileUrl($baseUrl ?? $this->requestedDomainUrl() ?? ''); + } + public function isUcpActive(SalesChannelEntity|string|null $salesChannel): bool { $salesChannelId = $salesChannel instanceof SalesChannelEntity ? $salesChannel->getId() : $salesChannel; @@ -34,4 +51,11 @@ public function isUcpActive(SalesChannelEntity|string|null $salesChannel): bool return $this->configService->getConfig($salesChannelId)->active; } + + private function requestedDomainUrl(): ?string + { + $mainRequest = $this->requestStack->getMainRequest(); + + return null === $mainRequest ? null : $this->domainResolver->resolveByRequest($mainRequest)?->baseUrl; + } } diff --git a/tests/Unit/AgenticAiCatalogTemplateTest.php b/tests/Unit/AgenticAiCatalogTemplateTest.php index f9c37e8b..511f7724 100644 --- a/tests/Unit/AgenticAiCatalogTemplateTest.php +++ b/tests/Unit/AgenticAiCatalogTemplateTest.php @@ -5,6 +5,7 @@ namespace Swag\AgenticCommerce\Tests\Unit; use PHPUnit\Framework\TestCase; +use Swag\AgenticCommerce\Ucp\Profile\UcpProfileLocator; use Twig\Environment; use Twig\Loader\FilesystemLoader; use Twig\TwigFunction; @@ -13,23 +14,42 @@ final class AgenticAiCatalogTemplateTest extends TestCase { public function testFallbackCatalogRendersUcpProfileWithAbsoluteUrl(): void + { + $catalog = $this->renderCatalog('https://shop.example.com'); + + static::assertSame('Demo shop', $catalog['host']['displayName']); + static::assertSame('https://shop.example.com/.well-known/ucp', $catalog['entries'][0]['url']); + } + + public function testFallbackCatalogListsNoUcpProfileForADomainWithAPath(): void + { + $catalog = $this->renderCatalog('https://shop.example.com/de'); + + static::assertSame([], $catalog['entries']); + } + + /** + * @return array + */ + private function renderCatalog(string $baseUrl): array { $twig = new Environment($this->createLoader()); $twig->addFunction(new TwigFunction('swag_agentic_commerce_ucp_active', static fn (): bool => true)); + $twig->addFunction(new TwigFunction('swag_agentic_commerce_ucp_profile_url', (new UcpProfileLocator())->profileUrl(...))); + /** @var array $catalog */ $catalog = json_decode($twig->render('files/agentic/.well-known/ai-catalog.json.twig', [ 'salesChannel' => [ 'name' => 'Demo shop', 'translated' => ['name' => 'Demo shop'], ], 'salesChannelFileContext' => [ - 'baseUrl' => 'https://shop.example.com', + 'baseUrl' => $baseUrl, 'publisher' => 'shop.example.com', ], ]), true, 512, \JSON_THROW_ON_ERROR); - static::assertSame('Demo shop', $catalog['host']['displayName']); - static::assertSame('https://shop.example.com/.well-known/ucp', $catalog['entries'][0]['url']); + return $catalog; } private function createLoader(): FilesystemLoader diff --git a/tests/Unit/ApiCatalog/ApiCatalogControllerTest.php b/tests/Unit/ApiCatalog/ApiCatalogControllerTest.php index 4a72ade7..91817293 100644 --- a/tests/Unit/ApiCatalog/ApiCatalogControllerTest.php +++ b/tests/Unit/ApiCatalog/ApiCatalogControllerTest.php @@ -22,6 +22,7 @@ use Swag\AgenticCommerce\Ucp\Config\UcpConfig; use Swag\AgenticCommerce\Ucp\Config\UcpConfigRepositoryInterface; use Swag\AgenticCommerce\Ucp\Config\UcpConfigService; +use Swag\AgenticCommerce\Ucp\Profile\UcpProfileLocator; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -37,7 +38,7 @@ public function testItServesALinksetWithApiCatalogLinkHeaderForExposedSalesChann $controller = new ApiCatalogController( $this->configService(new UcpConfig(active: true)), - new ApiCatalogLinksetBuilder(), + new ApiCatalogLinksetBuilder(new UcpProfileLocator()), $this->baseUrlResolver($salesChannelId, 'https://shop.example.com/'), ); @@ -76,7 +77,7 @@ public function testItKeepsSlashesUnescapedInTheBody(): void $controller = new ApiCatalogController( $this->configService(new UcpConfig(active: true)), - new ApiCatalogLinksetBuilder(), + new ApiCatalogLinksetBuilder(new UcpProfileLocator()), $this->baseUrlResolver($salesChannelId, 'https://shop.example.com/'), ); @@ -92,7 +93,7 @@ public function testItReturns404ForUnexposedSalesChannels(): void $controller = new ApiCatalogController( $this->configService(new UcpConfig(active: false)), - new ApiCatalogLinksetBuilder(), + new ApiCatalogLinksetBuilder(new UcpProfileLocator()), $this->baseUrlResolver($salesChannelId, 'https://shop.example.com/'), ); diff --git a/tests/Unit/ApiCatalog/ApiCatalogLinksetBuilderTest.php b/tests/Unit/ApiCatalog/ApiCatalogLinksetBuilderTest.php index 84c5f07f..96188450 100644 --- a/tests/Unit/ApiCatalog/ApiCatalogLinksetBuilderTest.php +++ b/tests/Unit/ApiCatalog/ApiCatalogLinksetBuilderTest.php @@ -7,6 +7,7 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use Swag\AgenticCommerce\AgenticFiles\ApiCatalog\ApiCatalogLinksetBuilder; +use Swag\AgenticCommerce\Ucp\Profile\UcpProfileLocator; /** * @internal @@ -16,15 +17,31 @@ final class ApiCatalogLinksetBuilderTest extends TestCase { public function testItLinksUcpProfileAndStoreApiRelativeToTheBaseUrl(): void { - $result = (new ApiCatalogLinksetBuilder())->build('https://shop.example.com/en'); + $result = $this->builder()->build('https://shop.example.com'); static::assertSame([ 'linkset' => [ [ - 'anchor' => 'https://shop.example.com/en/.well-known/api-catalog', + 'anchor' => 'https://shop.example.com/.well-known/api-catalog', 'service-meta' => [ - ['href' => 'https://shop.example.com/en/.well-known/ucp', 'type' => 'application/json'], + ['href' => 'https://shop.example.com/.well-known/ucp', 'type' => 'application/json'], ], + 'item' => [ + ['href' => 'https://shop.example.com/store-api', 'type' => 'application/json'], + ], + ], + ], + ], $result); + } + + public function testItLinksNoUcpProfileForABaseUrlWithAPath(): void + { + $result = $this->builder()->build('https://shop.example.com/en'); + + static::assertSame([ + 'linkset' => [ + [ + 'anchor' => 'https://shop.example.com/en/.well-known/api-catalog', 'item' => [ ['href' => 'https://shop.example.com/en/store-api', 'type' => 'application/json'], ], @@ -35,7 +52,7 @@ public function testItLinksUcpProfileAndStoreApiRelativeToTheBaseUrl(): void public function testItEmitsRootRelativeReferencesForAnEmptyBaseUrl(): void { - $result = (new ApiCatalogLinksetBuilder())->build(''); + $result = $this->builder()->build(''); static::assertSame([ 'linkset' => [ @@ -51,4 +68,9 @@ public function testItEmitsRootRelativeReferencesForAnEmptyBaseUrl(): void ], ], $result); } + + private function builder(): ApiCatalogLinksetBuilder + { + return new ApiCatalogLinksetBuilder(new UcpProfileLocator()); + } } diff --git a/tests/Unit/EmbeddedResponseListenerTest.php b/tests/Unit/EmbeddedResponseListenerTest.php index a18ffe98..e1045c19 100644 --- a/tests/Unit/EmbeddedResponseListenerTest.php +++ b/tests/Unit/EmbeddedResponseListenerTest.php @@ -11,6 +11,10 @@ use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository; use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria; use Shopware\Core\Framework\DataAbstractionLayer\Search\EntitySearchResult; +use Shopware\Core\System\SalesChannel\Aggregate\SalesChannelDomain\SalesChannelDomainCollection; +use Shopware\Core\System\SalesChannel\Aggregate\SalesChannelDomain\SalesChannelDomainEntity; +use Shopware\Core\Test\Stub\DataAbstractionLayer\StaticEntityRepository; +use Shopware\Storefront\Framework\Routing\RequestTransformer; use Swag\AgenticCommerce\Ucp\Config\LegacyConfigStoreInterface; use Swag\AgenticCommerce\Ucp\Config\UcpConfig; use Swag\AgenticCommerce\Ucp\Config\UcpConfigRepositoryInterface; @@ -98,6 +102,34 @@ public function testItAllowsConfiguredEmbeddedOrigin(): void self::assertSame(['https://assistant.example'], $event->getRequest()->attributes->get('ucp_embedded_allowed_origins')); } + #[Test] + public function testItAppliesThePathPrefixedChannelsFrameAncestorsAfterCoreStrippedThePrefix(): void + { + $ucpConfigRepository = $this->createMock(UcpConfigRepositoryInterface::class); + $ucpConfigRepository->method('find')->willReturnCallback(static fn (string $salesChannelId): ?UcpConfig => 'sales-channel-de' === $salesChannelId + ? new UcpConfig(active: true, embeddedAllowedOrigins: ['https://assistant.example'], embeddedFrameAncestors: ['https://assistant.example']) + : null); + $pathPrefixedDomain = new SalesChannelDomainEntity(); + $pathPrefixedDomain->setId('domain-de'); + $pathPrefixedDomain->setSalesChannelId('sales-channel-de'); + $pathPrefixedDomain->setLanguageId('language-id'); + $pathPrefixedDomain->setCurrencyId('currency-id'); + $pathPrefixedDomain->setUrl('https://shop.example/de'); + /** @var StaticEntityRepository $domainRepository */ + $domainRepository = new StaticEntityRepository([new SalesChannelDomainCollection([$pathPrefixedDomain])]); + $listener = new EmbeddedResponseListener( + new UcpConfigService($ucpConfigRepository, $this->createStub(LegacyConfigStoreInterface::class)), + new SalesChannelDomainResolver($domainRepository), + ); + $embeddedCartRequest = Request::create('https://shop.example/ucp/embedded/cart/cart-id'); + $embeddedCartRequest->attributes->set(RequestTransformer::ORIGINAL_REQUEST_URI, '/de/ucp/embedded/cart/cart-id'); + $event = new ResponseEvent($this->kernel, $embeddedCartRequest, HttpKernelInterface::MAIN_REQUEST, new Response()); + + $listener->onKernelResponse($event); + + self::assertSame('frame-ancestors https://assistant.example', $event->getResponse()->headers->get('Content-Security-Policy')); + } + /** * Load-bearing invariant, not an oversight: browsers omit the `Origin` header on * iframe and top-level GET navigations, which is how the embedded surface is diff --git a/tests/Unit/SalesChannelDomainResolverTest.php b/tests/Unit/SalesChannelDomainResolverTest.php index 76602495..ba03f659 100644 --- a/tests/Unit/SalesChannelDomainResolverTest.php +++ b/tests/Unit/SalesChannelDomainResolverTest.php @@ -15,8 +15,10 @@ use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\MultiFilter; use Shopware\Core\System\SalesChannel\Aggregate\SalesChannelDomain\SalesChannelDomainCollection; use Shopware\Core\System\SalesChannel\Aggregate\SalesChannelDomain\SalesChannelDomainEntity; +use Shopware\Storefront\Framework\Routing\RequestTransformer; use Swag\AgenticCommerce\Ucp\SalesChannel\SalesChannelDomainResolver; use Swag\AgenticCommerce\Ucp\SalesChannel\SalesChannelDomainResolverCacheInvalidator; +use Symfony\Component\HttpFoundation\Request; use Symfony\Contracts\Cache\ItemInterface; use Symfony\Contracts\Cache\TagAwareCacheInterface; @@ -128,6 +130,29 @@ public function testItResolvesSameHostVirtualPathsToDifferentDomains(): void static::assertSame('sales-channel-en', $englishResolution->salesChannelId); } + public function testItResolvesATransformedRequestByTheUriBeforeCoreStrippedThePathPrefix(): void + { + $resolver = new SalesChannelDomainResolver($this->domainRepository([ + $this->domain('domain-root', 'sales-channel-root', 'https://shop.example'), + $this->domain('domain-de', 'sales-channel-de', 'https://shop.example/de'), + ])); + $transformedRequest = Request::create('https://shop.example/.well-known/ucp'); + $transformedRequest->attributes->set(RequestTransformer::ORIGINAL_REQUEST_URI, '/de/.well-known/ucp'); + + static::assertSame('https://shop.example/de/.well-known/ucp', SalesChannelDomainResolver::absoluteRequestUri($transformedRequest)); + static::assertSame('sales-channel-de', $resolver->resolveByRequest($transformedRequest)?->salesChannelId); + } + + public function testItResolvesAnUntransformedRequestByItsOwnUri(): void + { + $resolver = new SalesChannelDomainResolver($this->domainRepository([ + $this->domain('domain-root', 'sales-channel-root', 'https://shop.example'), + $this->domain('domain-de', 'sales-channel-de', 'https://shop.example/de'), + ])); + + static::assertSame('sales-channel-de', $resolver->resolveByRequest(Request::create('https://shop.example/de/products'))?->salesChannelId); + } + public function testItMatchesPunycodeRequestHostAgainstUnicodeDomain(): void { $resolver = new SalesChannelDomainResolver($this->domainRepository([ diff --git a/tests/Unit/ShopwareRuntimeConfigurationResolverTest.php b/tests/Unit/ShopwareRuntimeConfigurationResolverTest.php new file mode 100644 index 00000000..664f65c9 --- /dev/null +++ b/tests/Unit/ShopwareRuntimeConfigurationResolverTest.php @@ -0,0 +1,104 @@ +attributes->set(RequestTransformer::ORIGINAL_REQUEST_URI, '/de/ucp/v1/carts'); + + $runtimeConfiguration = $this->resolver($cartRequestWithStrippedPrefix) + ->resolve(new HttpRequest('POST', 'https://shop.example/ucp/v1/carts', [], [], '')); + + static::assertSame('sales-channel-de', $runtimeConfiguration->tenantIdentifier); + static::assertSame('https://shop.example/de', $runtimeConfiguration->baseUri); + } + + #[Test] + public function testItResolvesThePathPrefixedChannelOfTheMainRequestWhileASubRequestIsCurrent(): void + { + $mcpRequestWithStrippedPrefix = Request::create('https://shop.example/ucp/mcp', Request::METHOD_POST); + $mcpRequestWithStrippedPrefix->attributes->set(RequestTransformer::ORIGINAL_REQUEST_URI, '/de/ucp/mcp'); + + $mainRequestChannelConfiguration = $this->resolver($mcpRequestWithStrippedPrefix, Request::create('https://shop.example/store-api/_mcp', Request::METHOD_POST)) + ->resolve(new HttpRequest('POST', 'https://shop.example/ucp/mcp', [], [], '')); + + static::assertSame('sales-channel-de', $mainRequestChannelConfiguration->tenantIdentifier); + } + + #[Test] + public function testItIgnoresTheOriginalUriOfAnotherRequestOnTheStack(): void + { + $unrelatedRequest = Request::create('https://shop.example/checkout'); + $unrelatedRequest->attributes->set(RequestTransformer::ORIGINAL_REQUEST_URI, '/de/checkout'); + + $runtimeConfiguration = $this->resolver($unrelatedRequest) + ->resolve(new HttpRequest('POST', 'https://shop.example/ucp/v1/carts', [], [], '')); + + static::assertSame('sales-channel-root', $runtimeConfiguration->tenantIdentifier); + static::assertSame('https://shop.example', $runtimeConfiguration->baseUri); + } + + private function resolver(Request $mainRequest, ?Request $subRequest = null): ShopwareRuntimeConfigurationResolver + { + $ucpConfigRepository = $this->createStub(UcpConfigRepositoryInterface::class); + $ucpConfigRepository->method('find')->willReturn(new UcpConfig(active: true)); + + /** @var StaticEntityRepository $domainRepository */ + $domainRepository = new StaticEntityRepository([new SalesChannelDomainCollection([ + $this->domain('domain-root', 'sales-channel-root', 'https://shop.example'), + $this->domain('domain-de', 'sales-channel-de', 'https://shop.example/de'), + ])]); + + $requestStack = new RequestStack(); + $requestStack->push($mainRequest); + if (null !== $subRequest) { + $requestStack->push($subRequest); + } + + return new ShopwareRuntimeConfigurationResolver( + new UcpConfigService($ucpConfigRepository, $this->createStub(LegacyConfigStoreInterface::class)), + new SalesChannelDomainResolver($domainRepository), + new ShopwareVersionDetector('6.7.0.0'), + new PathPrefixRestorer($requestStack), + ); + } + + private function domain(string $id, string $salesChannelId, string $url): SalesChannelDomainEntity + { + $domain = new SalesChannelDomainEntity(); + $domain->setId($id); + $domain->setSalesChannelId($salesChannelId); + $domain->setLanguageId('language-id'); + $domain->setCurrencyId('currency-id'); + $domain->setUrl($url); + + return $domain; + } +} diff --git a/tests/Unit/Storefront/Subscriber/UcpProfileLinkHeaderSubscriberTest.php b/tests/Unit/Storefront/Subscriber/UcpProfileLinkHeaderSubscriberTest.php index 9b53c0af..d2e57bd0 100644 --- a/tests/Unit/Storefront/Subscriber/UcpProfileLinkHeaderSubscriberTest.php +++ b/tests/Unit/Storefront/Subscriber/UcpProfileLinkHeaderSubscriberTest.php @@ -12,11 +12,13 @@ use Shopware\Core\System\SalesChannel\Aggregate\SalesChannelDomain\SalesChannelDomainCollection; use Shopware\Core\System\SalesChannel\Aggregate\SalesChannelDomain\SalesChannelDomainEntity; use Shopware\Core\Test\Stub\DataAbstractionLayer\StaticEntityRepository; +use Shopware\Storefront\Framework\Routing\RequestTransformer; use Swag\AgenticCommerce\Storefront\Subscriber\UcpProfileLinkHeaderSubscriber; use Swag\AgenticCommerce\Ucp\Config\LegacyConfigStoreInterface; use Swag\AgenticCommerce\Ucp\Config\UcpConfig; use Swag\AgenticCommerce\Ucp\Config\UcpConfigRepositoryInterface; use Swag\AgenticCommerce\Ucp\Config\UcpConfigService; +use Swag\AgenticCommerce\Ucp\Profile\UcpProfileLocator; use Swag\AgenticCommerce\Ucp\SalesChannel\SalesChannelDomainResolver; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -63,6 +65,19 @@ public function itDoesNotAddTheLinkForAnUnknownSalesChannelDomain(): void static::assertSame([], $response->headers->all('Link')); } + #[Test] + public function itAddsNoLinkForAPathPrefixedDomain(): void + { + $htmlResponse = new Response('', Response::HTTP_OK, ['Content-Type' => 'text/html']); + $requestWithStrippedPrefix = Request::create('https://shop.example/products'); + $requestWithStrippedPrefix->attributes->set(RequestTransformer::ORIGINAL_REQUEST_URI, '/de/products'); + + $this->subscriber(new UcpConfig(active: true), $this->domain('https://shop.example/de')) + ->onBeforeSendResponse(new BeforeSendResponseEvent($requestWithStrippedPrefix, $htmlResponse)); + + static::assertSame([], $htmlResponse->headers->all('Link')); + } + private function subscriber( UcpConfig $config, ?SalesChannelDomainEntity $domain = null, @@ -80,7 +95,7 @@ private function subscriber( ]); $domainResolver = new SalesChannelDomainResolver($domainRepository); - return new UcpProfileLinkHeaderSubscriber($configService, $domainResolver); + return new UcpProfileLinkHeaderSubscriber($configService, $domainResolver, new UcpProfileLocator()); } private function responseEvent(Response $response): BeforeSendResponseEvent @@ -88,14 +103,14 @@ private function responseEvent(Response $response): BeforeSendResponseEvent return new BeforeSendResponseEvent(Request::create('https://shop.example/products'), $response); } - private function domain(): SalesChannelDomainEntity + private function domain(string $url = 'https://shop.example'): SalesChannelDomainEntity { $domain = new SalesChannelDomainEntity(); $domain->setId('domain-id'); $domain->setSalesChannelId('sales-channel-id'); $domain->setLanguageId('language-id'); $domain->setCurrencyId('currency-id'); - $domain->setUrl('https://shop.example'); + $domain->setUrl($url); return $domain; } diff --git a/tests/Unit/Ucp/Command/UcpSetupCommandTest.php b/tests/Unit/Ucp/Command/UcpSetupCommandTest.php index c9f0f3f9..1d28392c 100644 --- a/tests/Unit/Ucp/Command/UcpSetupCommandTest.php +++ b/tests/Unit/Ucp/Command/UcpSetupCommandTest.php @@ -29,6 +29,7 @@ use Swag\AgenticCommerce\Ucp\Config\UcpConfigRepositoryInterface; use Swag\AgenticCommerce\Ucp\Config\UcpConfigService; use Swag\AgenticCommerce\Ucp\Config\Validation\UcpConfigValidator; +use Swag\AgenticCommerce\Ucp\Profile\UcpProfileLocator; use Swag\AgenticCommerce\Ucp\SalesChannel\SalesChannelViewProvider; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Tester\CommandTester; @@ -191,6 +192,7 @@ private function command(UcpConfigRepositoryInterface $configRepository, UcpSign $configService, new UcpSigningKeyService($keys, new UcpSigningKeyServiceTestSigningKeyManager()), new UcpConfigValidator(), + new UcpProfileLocator(), ); } diff --git a/tests/Unit/Ucp/Config/Validation/UcpConfigValidatorTest.php b/tests/Unit/Ucp/Config/Validation/UcpConfigValidatorTest.php index 5fac23fb..f9c21e12 100644 --- a/tests/Unit/Ucp/Config/Validation/UcpConfigValidatorTest.php +++ b/tests/Unit/Ucp/Config/Validation/UcpConfigValidatorTest.php @@ -134,6 +134,28 @@ public function testProfileDomainNotServedByChannelIsAWarning(): void $this->assertFindingSeverity($findings, 'profile_domain_unmatched', Severity::Warning); } + public function testAChannelOnlyOnPathPrefixedDomainsIsAWarning(): void + { + $findings = $this->validate( + $this->config(), + [$this->activeKey()], + [['url' => 'https://shop.example.com/de'], ['url' => 'https://shop.example.com/at/']], + ); + + $this->assertFindingSeverity($findings, 'path_prefixed_domain', Severity::Warning); + } + + public function testAPathPrefixedDomainNextToARootDomainDoesNotWarn(): void + { + $findings = $this->validate( + $this->config(), + [$this->activeKey()], + [['url' => 'https://shop.example.com/de'], ['url' => 'https://de.shop.example.com/']], + ); + + static::assertNotContains('path_prefixed_domain', $this->codes($findings)); + } + public function testIdempotencyDisabledIsAWarning(): void { $findings = $this->validate($this->config(idempotencyRequired: false), [$this->activeKey()], self::DOMAINS); diff --git a/tests/Unit/Ucp/Http/PathPrefixRestoringRequestContextFactoryTest.php b/tests/Unit/Ucp/Http/PathPrefixRestoringRequestContextFactoryTest.php new file mode 100644 index 00000000..89c7afd2 --- /dev/null +++ b/tests/Unit/Ucp/Http/PathPrefixRestoringRequestContextFactoryTest.php @@ -0,0 +1,67 @@ +attributes->set(RequestTransformer::ORIGINAL_REQUEST_URI, '/de/ucp/v1/carts?expand=items'); + + $innerFactory = $this->createMock(HttpRequestContextFactoryInterface::class); + $innerFactory->expects(static::once())->method('create')->with(static::equalTo(new HttpRequest( + 'POST', + 'https://shop.example/de/ucp/v1/carts?expand=items', + ['ucp-agent' => 'profile="https://agent.example/profile.json"'], + ['expand' => 'items'], + '{"line_items":[]}', + )))->willReturn(new RequestContext('shop.example')); + + $this->factory($innerFactory, $cartRequestWithStrippedPrefix)->create(new HttpRequest( + 'POST', + 'https://shop.example/ucp/v1/carts?expand=items', + ['ucp-agent' => 'profile="https://agent.example/profile.json"'], + ['expand' => 'items'], + '{"line_items":[]}', + )); + } + + public function testItKeepsTheUriOfARequestNotOnTheStack(): void + { + $unrelatedRequest = Request::create('https://shop.example/checkout'); + $unrelatedRequest->attributes->set(RequestTransformer::ORIGINAL_REQUEST_URI, '/de/checkout'); + + $innerFactory = $this->createMock(HttpRequestContextFactoryInterface::class); + $innerFactory->expects(static::once())->method('create')->with(static::equalTo(new HttpRequest('POST', 'https://example.invalid'))) + ->willReturn(new RequestContext('example.invalid')); + + $this->factory($innerFactory, $unrelatedRequest)->create(new HttpRequest('POST', 'https://example.invalid')); + } + + private function factory(HttpRequestContextFactoryInterface $innerFactory, Request $mainRequest): PathPrefixRestoringRequestContextFactory + { + $requestStack = new RequestStack(); + $requestStack->push($mainRequest); + + return new PathPrefixRestoringRequestContextFactory($innerFactory, new PathPrefixRestorer($requestStack)); + } +} diff --git a/tests/Unit/Ucp/Http/PathPrefixRestoringResponseSignatureServiceTest.php b/tests/Unit/Ucp/Http/PathPrefixRestoringResponseSignatureServiceTest.php new file mode 100644 index 00000000..821250a4 --- /dev/null +++ b/tests/Unit/Ucp/Http/PathPrefixRestoringResponseSignatureServiceTest.php @@ -0,0 +1,54 @@ +attributes->set(RequestTransformer::ORIGINAL_REQUEST_URI, '/de/ucp/v1/carts/c1'); + $requestStack = new RequestStack(); + $requestStack->push($cartRequestWithStrippedPrefix); + + $cartResponse = new HttpResponse(200, ['content-type' => 'application/json'], '{}'); + $signingKey = new ManagedSigningKey('shop-key', 'public-key-pem', 'private-key-pem'); + + $innerService = $this->createMock(ResponseSignatureServiceInterface::class); + $innerService->expects(static::once())->method('sign')->with( + $cartResponse, + static::equalTo(new HttpRequest('GET', 'https://shop.example/de/ucp/v1/carts/c1', ['ucp-agent' => 'profile="https://agent.example/profile.json"'])), + $signingKey, + 1700000000, + 1700000300, + )->willReturn(['signature' => 'sig1=:c2lnbmF0dXJl:']); + + $signatureHeaders = (new PathPrefixRestoringResponseSignatureService($innerService, new PathPrefixRestorer($requestStack)))->sign( + $cartResponse, + new HttpRequest('GET', 'https://shop.example/ucp/v1/carts/c1', ['ucp-agent' => 'profile="https://agent.example/profile.json"']), + $signingKey, + 1700000000, + 1700000300, + ); + + static::assertSame(['signature' => 'sig1=:c2lnbmF0dXJl:'], $signatureHeaders); + } +} diff --git a/tests/Unit/Ucp/Profile/PathPrefixedProfileNotFoundListenerTest.php b/tests/Unit/Ucp/Profile/PathPrefixedProfileNotFoundListenerTest.php new file mode 100644 index 00000000..abd742bb --- /dev/null +++ b/tests/Unit/Ucp/Profile/PathPrefixedProfileNotFoundListenerTest.php @@ -0,0 +1,84 @@ +attributes->set(RequestTransformer::ORIGINAL_REQUEST_URI, '/de/.well-known/ucp'); + + $this->expectException(NotFoundHttpException::class); + + $this->listener()->onRequest($this->requestEvent($profileRequestWithStrippedPrefix)); + } + + public function testTheProfileAtTheOriginRootIsServed(): void + { + $rootProfileRequest = Request::create('https://shop.example/.well-known/ucp'); + $rootProfileRequest->attributes->set(RequestTransformer::ORIGINAL_REQUEST_URI, '/.well-known/ucp'); + + $this->listener()->onRequest($this->requestEvent($rootProfileRequest)); + + $this->addToAssertionCount(1); + } + + public function testOtherPathsBelowAPathPrefixAreServed(): void + { + $cartRequestWithStrippedPrefix = Request::create('https://shop.example/ucp/v1/carts'); + $cartRequestWithStrippedPrefix->attributes->set(RequestTransformer::ORIGINAL_REQUEST_URI, '/de/ucp/v1/carts'); + + $this->listener()->onRequest($this->requestEvent($cartRequestWithStrippedPrefix)); + + $this->addToAssertionCount(1); + } + + private function listener(): PathPrefixedProfileNotFoundListener + { + /** @var StaticEntityRepository $domainRepository */ + $domainRepository = new StaticEntityRepository([new SalesChannelDomainCollection([ + $this->domain('domain-root', 'https://shop.example'), + $this->domain('domain-de', 'https://shop.example/de'), + ])]); + + return new PathPrefixedProfileNotFoundListener(new SalesChannelDomainResolver($domainRepository), new UcpProfileLocator()); + } + + private function requestEvent(Request $request): RequestEvent + { + return new RequestEvent($this->createStub(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST); + } + + private function domain(string $id, string $url): SalesChannelDomainEntity + { + $registeredDomain = new SalesChannelDomainEntity(); + $registeredDomain->setId($id); + $registeredDomain->setSalesChannelId('sales-channel-'.$id); + $registeredDomain->setLanguageId('language-id'); + $registeredDomain->setCurrencyId('currency-id'); + $registeredDomain->setUrl($url); + + return $registeredDomain; + } +} diff --git a/tests/Unit/Ucp/Profile/UcpProfileLocatorTest.php b/tests/Unit/Ucp/Profile/UcpProfileLocatorTest.php new file mode 100644 index 00000000..039efa35 --- /dev/null +++ b/tests/Unit/Ucp/Profile/UcpProfileLocatorTest.php @@ -0,0 +1,33 @@ +profileUrl('https://shop.example')); + static::assertSame('https://shop.example/.well-known/ucp', (new UcpProfileLocator())->profileUrl('https://shop.example/')); + } + + public function testADomainWithAPathPublishesNoProfile(): void + { + static::assertNull((new UcpProfileLocator())->profileUrl('https://shop.example/de')); + static::assertNull((new UcpProfileLocator())->profileUrl('https://shop.example/de/')); + } + + public function testAnEmptyDomainUrlYieldsTheRootRelativeProfilePath(): void + { + static::assertSame('/.well-known/ucp', (new UcpProfileLocator())->profileUrl('')); + } +} diff --git a/tests/Unit/UcpAgenticFilesExtensionTest.php b/tests/Unit/UcpAgenticFilesExtensionTest.php index 3a6c8788..8a476ba8 100644 --- a/tests/Unit/UcpAgenticFilesExtensionTest.php +++ b/tests/Unit/UcpAgenticFilesExtensionTest.php @@ -6,12 +6,20 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +use Shopware\Core\System\SalesChannel\Aggregate\SalesChannelDomain\SalesChannelDomainCollection; +use Shopware\Core\System\SalesChannel\Aggregate\SalesChannelDomain\SalesChannelDomainEntity; use Shopware\Core\System\SalesChannel\SalesChannelEntity; +use Shopware\Core\Test\Stub\DataAbstractionLayer\StaticEntityRepository; +use Shopware\Storefront\Framework\Routing\RequestTransformer; use Swag\AgenticCommerce\Ucp\Config\LegacyConfigStoreInterface; use Swag\AgenticCommerce\Ucp\Config\UcpConfig; use Swag\AgenticCommerce\Ucp\Config\UcpConfigRepositoryInterface; use Swag\AgenticCommerce\Ucp\Config\UcpConfigService; +use Swag\AgenticCommerce\Ucp\Profile\UcpProfileLocator; +use Swag\AgenticCommerce\Ucp\SalesChannel\SalesChannelDomainResolver; use Swag\AgenticCommerce\Ucp\Twig\UcpAgenticFilesExtension; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; /** @internal */ #[CoversClass(UcpAgenticFilesExtension::class)] @@ -22,6 +30,8 @@ final class UcpAgenticFilesExtensionTest extends TestCase private UcpAgenticFilesExtension $extension; + private RequestStack $requestStack; + protected function setUp(): void { $repository = $this->createMock(UcpConfigRepositoryInterface::class); @@ -37,7 +47,19 @@ protected function setUp(): void $legacyStore = $this->createMock(LegacyConfigStoreInterface::class); $legacyStore->method('get')->willReturn(null); - $this->extension = new UcpAgenticFilesExtension(new UcpConfigService($repository, $legacyStore)); + /** @var StaticEntityRepository $domainRepository */ + $domainRepository = new StaticEntityRepository([new SalesChannelDomainCollection([ + $this->domain('domain-root', 'https://shop.example'), + $this->domain('domain-de', 'https://shop.example/de'), + ])]); + $this->requestStack = new RequestStack(); + + $this->extension = new UcpAgenticFilesExtension( + new UcpConfigService($repository, $legacyStore), + new UcpProfileLocator(), + new SalesChannelDomainResolver($domainRepository), + $this->requestStack, + ); } public function testItExposesConfiguredSalesChannelAsActive(): void @@ -75,5 +97,38 @@ public function testItDoesNotExposeInactiveOrMissingSalesChannels(): void public function testItRegistersTwigFunction(): void { static::assertSame('swag_agentic_commerce_ucp_active', $this->extension->getFunctions()[0]->getName()); + static::assertSame('swag_agentic_commerce_ucp_profile_url', $this->extension->getFunctions()[1]->getName()); + } + + public function testItGivesTheProfileUrlOnlyForADomainWithoutAPath(): void + { + static::assertSame('https://shop.example/.well-known/ucp', $this->extension->ucpProfileUrl('https://shop.example')); + static::assertNull($this->extension->ucpProfileUrl('https://shop.example/de')); + } + + public function testWithoutABaseUrlItGivesNoProfileUrlBelowThePathPrefixOfTheRequestedDomain(): void + { + $llmsTxtRequestWithStrippedPrefix = Request::create('https://shop.example/llms.txt'); + $llmsTxtRequestWithStrippedPrefix->attributes->set(RequestTransformer::ORIGINAL_REQUEST_URI, '/de/llms.txt'); + $this->requestStack->push($llmsTxtRequestWithStrippedPrefix); + + static::assertNull($this->extension->ucpProfileUrl()); + } + + public function testWithoutABaseUrlOrARequestItGivesTheRootRelativeProfilePath(): void + { + static::assertSame('/.well-known/ucp', $this->extension->ucpProfileUrl()); + } + + private function domain(string $id, string $url): SalesChannelDomainEntity + { + $registeredDomain = new SalesChannelDomainEntity(); + $registeredDomain->setId($id); + $registeredDomain->setSalesChannelId('sales-channel-'.$id); + $registeredDomain->setLanguageId('language-id'); + $registeredDomain->setCurrencyId('currency-id'); + $registeredDomain->setUrl($url); + + return $registeredDomain; } } diff --git a/tests/acceptance/README.md b/tests/acceptance/README.md index f058aa2c..7a75a014 100644 --- a/tests/acceptance/README.md +++ b/tests/acceptance/README.md @@ -118,10 +118,8 @@ fixture reaches for a shop that the server-free run does not have. Every Playwright worker owns its test data. The ATS `DefaultSalesChannel` fixture gives each worker a storefront-type sales channel on the path-prefixed domain `${APP_URL}test-/`, so the -Administration shows the Agentic Commerce tab for it. That domain does not give the worker a profile -of its own yet: `/.well-known/ucp` under a prefixed domain resolves another channel (known blocker -D8). A spec that needs the real document creates a channel on a host of its own instead, see -`createStorefrontSalesChannelOnOwnHost()` below. The plugin fixtures build on the worker channel. +Administration shows the Agentic Commerce tab for it. The plugin fixtures build on the worker +channel. **`TestDataService`** (test scope) is `UcpTestDataService`, the ATS `TestDataService` plus UCP: `createStorefrontSalesChannel()`, `createHeadlessSalesChannel()`, `createFeedSalesChannel()`, diff --git a/tests/acceptance/known-blockers.ts b/tests/acceptance/known-blockers.ts index 5f44cbb1..d7b4ac76 100644 --- a/tests/acceptance/known-blockers.ts +++ b/tests/acceptance/known-blockers.ts @@ -62,13 +62,6 @@ export const knownBlockers: KnownBlocker[] = [ issueUrl: 'https://github.com/shopware/agentic-commerce/pull/150', reviewBy: '2026-12-31', }, - { - key: 'D8', - description: 'The UCP profile of a path-prefixed domain, such as /de/, advertises the root domain\'s endpoints and the capabilities of the root domain\'s sales channel. Core\'s RequestTransformer removes the path from the request URI before the plugin looks up the sales channel.', - owner: 'dgrothaus-sw', - issueUrl: 'https://github.com/shopware/agentic-commerce/issues/264', - reviewBy: '2026-12-31', - }, { key: 'D9', description: 'Below Shopware 6.7.12.0 the plugin\'s fallback controller serves the discovery files and answers 404 for all of them once UCP is switched off, although the files do not depend on UCP.', diff --git a/tests/acceptance/services/UcpTestDataService.ts b/tests/acceptance/services/UcpTestDataService.ts index 8e859ad6..0a6fc1d2 100644 --- a/tests/acceptance/services/UcpTestDataService.ts +++ b/tests/acceptance/services/UcpTestDataService.ts @@ -24,9 +24,9 @@ export interface ProductFeedOptions { } /** - * Parent domain of the per-channel hosts that give a channel a root-path domain of its own, so its - * `/.well-known/ucp` resolves it (a path prefix cannot, known blocker D8). The browser projects map - * it onto the `APP_URL` host with `--host-resolver-rules`; Node clients cannot resolve it. + * Parent domain of the per-channel hosts that give a channel a root-path domain of its own. The + * browser projects map it onto the `APP_URL` host with `--host-resolver-rules`; Node clients cannot + * resolve it. */ export const OWN_HOST_PARENT_DOMAIN = 'ucp-acceptance.test'; @@ -431,10 +431,6 @@ export class UcpTestDataService extends TestDataService { return ((await response.json()) as { data: UcpConfigPayload }).data; } - /** - * The profile the shop would serve for this channel, resolved by id. `/.well-known/ucp` under - * a path-prefixed domain cannot give this today (known blocker D8). - */ async getProfilePreview(salesChannelId: string): Promise { const response = await this.AdminApiClient.get(`./_admin/ucp/sales-channels/${salesChannelId}/profile-preview`); expect(response.ok(), await response.text()).toBeTruthy(); diff --git a/tests/acceptance/tests/KnownBlocked/path-prefixed-domain.spec.ts b/tests/acceptance/tests/KnownBlocked/path-prefixed-domain.spec.ts deleted file mode 100644 index 6c15c0da..00000000 --- a/tests/acceptance/tests/KnownBlocked/path-prefixed-domain.spec.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { expect, test } from '@fixtures/AcceptanceTest'; -import type { UcpProfileDocument } from '@services/UcpTestDataService'; - -/** - * Known blocker D8. Core's RequestTransformer strips the sales channel's path prefix from the - * request URI, and the plugin resolves the channel from that URI alone, so a prefixed domain - * serves the host's root channel or the global configuration instead of its own. - */ -test.describe('Path-prefixed sales channel domains @UcpKnownBlocked @UcpBlockedByD8', () => { - test('the worker channel publishes its own profile under its path prefix', async ({ TestDataService, DefaultSalesChannel, page }) => { - await TestDataService.activateUcp(DefaultSalesChannel.salesChannel.id); - - const profileResponse = await page.request.get(TestDataService.wellKnownUrl(DefaultSalesChannel.url)); - expect(profileResponse.ok(), await profileResponse.text()).toBeTruthy(); - - const profile = (await profileResponse.json()) as UcpProfileDocument; - const endpoints = Object.values(profile.ucp.services).flat().map(service => service.endpoint); - expect(endpoints.length, 'an activated channel advertises the shopping service').toBeGreaterThan(0); - for (const endpoint of endpoints) { - expect(endpoint, 'every advertised endpoint stays inside the worker channel').toContain(DefaultSalesChannel.url); - } - }); - - test('storefront pages point their service-meta link at the channel\'s own profile', async ({ TestDataService, request }) => { - const activatedChannel = await TestDataService.createStorefrontSalesChannel(); - await TestDataService.activateUcp(activatedChannel.salesChannel.id); - - const homePage = await request.get(activatedChannel.url); - const serviceMetaLink = homePage.headers()['link']?.match(/<([^>]+)>;\s*rel="service-meta"/)?.[1]; - - expect(serviceMetaLink).toBeDefined(); - expect(new URL(serviceMetaLink!, activatedChannel.url).href).toBe(TestDataService.wellKnownUrl(activatedChannel.url)); - }); - - test('a fresh channel on the same host advertises nothing until it is activated', async ({ TestDataService, page }) => { - const freshChannelOnSameHost = await TestDataService.createStorefrontSalesChannel(); - - const beforeActivation = await page.request.get(TestDataService.wellKnownUrl(freshChannelOnSameHost.url)); - expect(beforeActivation.ok(), await beforeActivation.text()).toBeTruthy(); - expect(((await beforeActivation.json()) as UcpProfileDocument).ucp.services).toEqual({}); - - await TestDataService.activateUcp(freshChannelOnSameHost.salesChannel.id); - - const afterActivation = await page.request.get(TestDataService.wellKnownUrl(freshChannelOnSameHost.url)); - expect(afterActivation.ok(), await afterActivation.text()).toBeTruthy(); - expect(Object.keys(((await afterActivation.json()) as UcpProfileDocument).ucp.services)).not.toEqual([]); - }); -}); diff --git a/tests/acceptance/tests/UcpContent/discovery-files.spec.ts b/tests/acceptance/tests/UcpContent/discovery-files.spec.ts index 2fefc19a..41e9efd9 100644 --- a/tests/acceptance/tests/UcpContent/discovery-files.spec.ts +++ b/tests/acceptance/tests/UcpContent/discovery-files.spec.ts @@ -15,18 +15,23 @@ test.describe('Agentic discovery files @UcpContent', () => { const ucpChannel = await TestDataService.createStorefrontSalesChannel(); await TestDataService.activateUcp(ucpChannel.salesChannel.id); - const bodyByFileName = new Map(); for (const [fileName, contentType] of Object.entries(DISCOVERY_FILE_CONTENT_TYPES)) { const discoveryFile = await request.get(`${ucpChannel.url}${fileName}`); - const discoveryFileBody = await discoveryFile.text(); - expect(discoveryFile.status(), `${fileName}: ${discoveryFileBody}`).toBe(200); + expect(discoveryFile.status(), `${fileName}: ${await discoveryFile.text()}`).toBe(200); expect(discoveryFile.headers()['content-type']).toContain(contentType); - bodyByFileName.set(fileName, discoveryFileBody); } + }); + + test('a UCP-active channel on a path-prefixed domain links no UCP profile from its discovery files', async ({ TestDataService, request }) => { + const pathPrefixedUcpChannel = await TestDataService.createStorefrontSalesChannel(); + await TestDataService.activateUcp(pathPrefixedUcpChannel.salesChannel.id); + + for (const fileName of Object.keys(DISCOVERY_FILE_CONTENT_TYPES)) { + const discoveryFileBody = await (await request.get(`${pathPrefixedUcpChannel.url}${fileName}`)).text(); - const aiCatalog = JSON.parse(bodyByFileName.get('.well-known/ai-catalog.json') ?? '') as { entries: { url: string }[] }; - expect(aiCatalog.entries.map(entry => entry.url)).toContain(TestDataService.wellKnownUrl(ucpChannel.url)); + expect(discoveryFileBody, `${fileName} links no profile below the path prefix`).not.toContain(UCP_PROFILE_PATH); + } }); test('/AGENTS.md serves the same document as /agents.md', async ({ InstanceMeta, TestDataService, request }) => { @@ -42,15 +47,11 @@ test.describe('Agentic discovery files @UcpContent', () => { expect(await upperCaseFileResponse.text()).toBe(await lowerCaseFileResponse.text()); }); - test('switching UCP off removes the UCP sections and keeps the files', async ({ InstanceMeta, TestDataService, request }) => { + test('switching UCP off keeps the discovery files', async ({ InstanceMeta, TestDataService, request }) => { test.skip(satisfies(InstanceMeta.version, FALLBACK_DROPS_FILES_WITHOUT_UCP), 'known blocker D9, asserted in tests/KnownBlocked/discovery-files.spec.ts'); const ucpChannel = await TestDataService.createStorefrontSalesChannel(); await TestDataService.activateUcp(ucpChannel.salesChannel.id); - for (const fileName of ['llms.txt', 'agents.md']) { - expect(await (await request.get(`${ucpChannel.url}${fileName}`)).text()).toContain(UCP_PROFILE_PATH); - } - const deactivationResponse = await TestDataService.saveUcpConfig(ucpChannel.salesChannel.id, { active: false }); expect(deactivationResponse.ok(), await deactivationResponse.text()).toBeTruthy(); @@ -60,7 +61,6 @@ test.describe('Agentic discovery files @UcpContent', () => { expect(discoveryFileResponseWithoutUcp.status()).toBe(200); expect(discoveryFileBodyWithoutUcp, `${fileName} still describes the channel`).toContain(ucpChannel.salesChannel.name); - expect(discoveryFileBodyWithoutUcp).not.toContain(UCP_PROFILE_PATH); } }); @@ -82,7 +82,7 @@ test.describe('Agentic discovery files @UcpContent', () => { } }); - test('the RFC 9727 api-catalog links the UCP profile and the Store API while UCP is active', async ({ TestDataService, request }) => { + test('the RFC 9727 api-catalog links the Store API while UCP is active', async ({ TestDataService, request }) => { const ucpChannel = await TestDataService.createStorefrontSalesChannel(); await TestDataService.activateUcp(ucpChannel.salesChannel.id); const apiCatalogUrl = `${ucpChannel.url}.well-known/api-catalog`; @@ -90,8 +90,8 @@ test.describe('Agentic discovery files @UcpContent', () => { const apiCatalogResponse = await request.get(apiCatalogUrl); expect(apiCatalogResponse.status()).toBe(200); expect(apiCatalogResponse.headers()['content-type']).toContain('application/linkset+json'); - const { linkset: [catalogEntry] } = (await apiCatalogResponse.json()) as { linkset: { 'service-meta': { href: string }[], item: { href: string }[] }[] }; - expect(catalogEntry['service-meta'].map(link => link.href)).toContain(TestDataService.wellKnownUrl(ucpChannel.url)); + const { linkset: [catalogEntry] } = (await apiCatalogResponse.json()) as { linkset: { 'service-meta'?: { href: string }[], item: { href: string }[] }[] }; + expect(catalogEntry['service-meta'], 'a path-prefixed domain links no UCP profile').toBeUndefined(); expect(catalogEntry.item.map(link => link.href)).toContain(`${ucpChannel.url}store-api`); const deactivationResponse = await TestDataService.saveUcpConfig(ucpChannel.salesChannel.id, { active: false }); diff --git a/tests/acceptance/tests/UcpProtocol/path-prefixed-domain.spec.ts b/tests/acceptance/tests/UcpProtocol/path-prefixed-domain.spec.ts new file mode 100644 index 00000000..f59dbdaf --- /dev/null +++ b/tests/acceptance/tests/UcpProtocol/path-prefixed-domain.spec.ts @@ -0,0 +1,33 @@ +import { expect, test } from '@fixtures/AcceptanceTest'; + +test.describe('Path-prefixed sales channel domains @UcpProtocol', () => { + test('an activated channel serves no UCP profile below its path prefix', async ({ TestDataService, DefaultSalesChannel, request }) => { + await TestDataService.activateUcp(DefaultSalesChannel.salesChannel.id); + + const profileResponse = await request.get(TestDataService.wellKnownUrl(DefaultSalesChannel.url)); + + expect(profileResponse.status(), await profileResponse.text()).toBe(404); + }); + + test('storefront pages below a path prefix carry no service-meta link', async ({ TestDataService, request }) => { + const activatedChannel = await TestDataService.createStorefrontSalesChannel(); + await TestDataService.activateUcp(activatedChannel.salesChannel.id); + + const homePage = await request.get(activatedChannel.url); + + expect(homePage.ok(), await homePage.text()).toBeTruthy(); + expect(homePage.headers()['link'] ?? '').not.toContain('rel="service-meta"'); + }); + + test('the channel\'s strict signature policy accepts the agent\'s signature and refuses a tampered one', async ({ TestDataService, UcpAgent }) => { + const strictChannel = await TestDataService.createStorefrontSalesChannel(); + await TestDataService.activateUcp(strictChannel.salesChannel.id, { signaturePolicy: 'strict' }); + const unknownCartPath = `/ucp/v1/carts/${crypto.randomUUID().replaceAll('-', '')}`; + + const untamperedSignatureAcceptance = await UcpAgent.at(strictChannel.url).rest.send('GET', unknownCartPath); + expect(untamperedSignatureAcceptance.status(), await untamperedSignatureAcceptance.text()).toBe(404); + + const tamperedSignatureRefusal = await UcpAgent.at(strictChannel.url).tampered({ tamperSignatureBytes: true }).send('GET', unknownCartPath); + expect(tamperedSignatureRefusal.status(), await tamperedSignatureRefusal.text()).toBe(401); + }); +});