From 7fbe0467ca9ce0167313f9421c43140f221a0349 Mon Sep 17 00:00:00 2001 From: Dominik Grothaus Date: Thu, 15 Oct 2026 15:08:56 +0200 Subject: [PATCH 1/3] fix: resolve a path-prefixed domain to its own sales channel The UCP profile, the UCP routes, the MCP proxy, the embedded pages and the storefront `service-meta` link looked up the sales channel from the request URI after core's `RequestTransformer` had removed the domain's path prefix. A domain such as `https://shop.example/de` therefore resolved to the host's root domain. They now resolve the sales channel from core's original request URI (`sw-original-request-uri`). The `service-meta` link now carries the domain's path. Request signatures are now verified against that original URI as well. The agent signs the path with the prefix, so under the `strict` signature policy every correctly signed request to such a domain was refused with `signature_invalid`. Added a decorator of the SDK's `HttpRequestContextFactoryInterface`, so the SDK receives the original URI. The acceptance spec for path-prefixed domains moved from `UcpKnownBlocked` to `UcpProtocol`. It covers the signature policy. Known blocker D8 was removed. --- CHANGELOG.md | 1 + CHANGELOG_de-DE.md | 1 + src/Resources/config/services.php | 10 ++ .../UcpProfileLinkHeaderSubscriber.php | 14 ++- .../ShopwareRuntimeConfigurationResolver.php | 4 +- src/Ucp/Embedded/EmbeddedResponseListener.php | 4 +- src/Ucp/Http/PathPrefixRestorer.php | 36 ++++++ ...thPrefixRestoringRequestContextFactory.php | 32 ++++++ ...refixRestoringResponseSignatureService.php | 44 ++++++++ src/Ucp/Mcp/Api/UcpMcpProxyController.php | 2 +- .../SalesChannelDomainResolver.php | 21 ++++ tests/Unit/EmbeddedResponseListenerTest.php | 32 ++++++ tests/Unit/SalesChannelDomainResolverTest.php | 25 +++++ ...opwareRuntimeConfigurationResolverTest.php | 104 ++++++++++++++++++ .../UcpProfileLinkHeaderSubscriberTest.php | 18 ++- ...efixRestoringRequestContextFactoryTest.php | 67 +++++++++++ ...xRestoringResponseSignatureServiceTest.php | 54 +++++++++ tests/acceptance/README.md | 6 +- tests/acceptance/known-blockers.ts | 7 -- .../acceptance/services/UcpTestDataService.ts | 10 +- .../path-prefixed-domain.spec.ts | 29 +++-- 21 files changed, 480 insertions(+), 41 deletions(-) create mode 100644 src/Ucp/Http/PathPrefixRestorer.php create mode 100644 src/Ucp/Http/PathPrefixRestoringRequestContextFactory.php create mode 100644 src/Ucp/Http/PathPrefixRestoringResponseSignatureService.php create mode 100644 tests/Unit/ShopwareRuntimeConfigurationResolverTest.php create mode 100644 tests/Unit/Ucp/Http/PathPrefixRestoringRequestContextFactoryTest.php create mode 100644 tests/Unit/Ucp/Http/PathPrefixRestoringResponseSignatureServiceTest.php rename tests/acceptance/tests/{KnownBlocked => UcpProtocol}/path-prefixed-domain.spec.ts (59%) diff --git a/CHANGELOG.md b/CHANGELOG.md index be123d9d..a142b240 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`. +- Give a sales channel whose domain has a path, such as `https://shop.example/de`, its own UCP profile. The profile under that domain advertised the endpoints and capabilities of the host's root domain, so an agent that discovered the `/de` shop was sent to the root shop. The UCP routes under that domain also applied the root domain's UCP settings. Under the `strict` signature policy, correctly signed requests to such a domain are no longer refused. Storefront pages of such a domain now point their `service-meta` link at the domain's own profile. # 1.4.0 diff --git a/CHANGELOG_de-DE.md b/CHANGELOG_de-DE.md index f2f4aa52..40a841c0 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. +- Ein Verkaufskanal, dessen Domain einen Pfad enthält, etwa `https://shop.example/de`, erhält jetzt ein eigenes UCP-Profil. Bisher führte das Profil unter dieser Domain die Endpunkte und Funktionen der Stammdomain des Hosts auf, sodass ein Agent, der den Shop unter `/de` gefunden hatte, zum Shop der Stammdomain geleitet wurde. Auch die UCP-Routen unter dieser Domain wendeten die UCP-Einstellungen der Stammdomain an. Gilt die Signaturrichtlinie `strict`, werden korrekt signierte Requests an eine solche Domain nicht mehr abgelehnt. Storefront-Seiten einer solchen Domain verweisen mit ihrem `service-meta`-Link jetzt auf das eigene Profil der Domain. # 1.4.0 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/Storefront/Subscriber/UcpProfileLinkHeaderSubscriber.php b/src/Storefront/Subscriber/UcpProfileLinkHeaderSubscriber.php index 73496059..4f0a04c2 100644 --- a/src/Storefront/Subscriber/UcpProfileLinkHeaderSubscriber.php +++ b/src/Storefront/Subscriber/UcpProfileLinkHeaderSubscriber.php @@ -14,7 +14,7 @@ #[Package('discovery')] final class UcpProfileLinkHeaderSubscriber implements EventSubscriberInterface { - private const UCP_PROFILE_LINK = '; rel="service-meta"'; + private const UCP_PROFILE_LINK = '<%s/.well-known/ucp>; rel="service-meta"'; public function __construct( private readonly UcpConfigService $configService, @@ -36,20 +36,22 @@ 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 + || !$this->isUcpActiveForSalesChannel($resolvedDomain->salesChannelId) ) { return; } + $domainPath = rtrim((string) parse_url($resolvedDomain->baseUrl, \PHP_URL_PATH), '/'); + $profileLink = \sprintf(self::UCP_PROFILE_LINK, $domainPath); $response = $event->getResponse(); - if ($response->headers->contains('Link', self::UCP_PROFILE_LINK)) { + if ($response->headers->contains('Link', $profileLink)) { return; } - $response->headers->set('Link', self::UCP_PROFILE_LINK, false); + $response->headers->set('Link', $profileLink, false); } private function isHtmlResponse(BeforeSendResponseEvent $event): bool 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/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/SalesChannel/SalesChannelDomainResolver.php b/src/Ucp/SalesChannel/SalesChannelDomainResolver.php index dbbee6a2..e97e6d30 100644 --- a/src/Ucp/SalesChannel/SalesChannelDomainResolver.php +++ b/src/Ucp/SalesChannel/SalesChannelDomainResolver.php @@ -11,6 +11,8 @@ 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 Symfony\Component\HttpFoundation\Request; use Symfony\Contracts\Cache\ItemInterface; use Symfony\Contracts\Cache\TagAwareCacheInterface; @@ -49,6 +51,25 @@ public function resolveByBaseUri(string $baseUri, ?Context $context = null): ?Sa return $this->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/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..9abbb790 100644 --- a/tests/Unit/Storefront/Subscriber/UcpProfileLinkHeaderSubscriberTest.php +++ b/tests/Unit/Storefront/Subscriber/UcpProfileLinkHeaderSubscriberTest.php @@ -12,6 +12,7 @@ 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; @@ -63,6 +64,19 @@ public function itDoesNotAddTheLinkForAnUnknownSalesChannelDomain(): void static::assertSame([], $response->headers->all('Link')); } + #[Test] + public function itAppendsTheLinkForAPathPrefixedDomainAfterCoreStrippedThePrefix(): 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(['; rel="service-meta"'], $htmlResponse->headers->all('Link')); + } + private function subscriber( UcpConfig $config, ?SalesChannelDomainEntity $domain = null, @@ -88,14 +102,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/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/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/UcpProtocol/path-prefixed-domain.spec.ts similarity index 59% rename from tests/acceptance/tests/KnownBlocked/path-prefixed-domain.spec.ts rename to tests/acceptance/tests/UcpProtocol/path-prefixed-domain.spec.ts index 6c15c0da..4a493895 100644 --- a/tests/acceptance/tests/KnownBlocked/path-prefixed-domain.spec.ts +++ b/tests/acceptance/tests/UcpProtocol/path-prefixed-domain.spec.ts @@ -1,16 +1,11 @@ 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 }) => { +test.describe('Path-prefixed sales channel domains @UcpProtocol', () => { + test('the worker channel publishes its own profile under its path prefix', async ({ TestDataService, DefaultSalesChannel, request }) => { await TestDataService.activateUcp(DefaultSalesChannel.salesChannel.id); - const profileResponse = await page.request.get(TestDataService.wellKnownUrl(DefaultSalesChannel.url)); + const profileResponse = await request.get(TestDataService.wellKnownUrl(DefaultSalesChannel.url)); expect(profileResponse.ok(), await profileResponse.text()).toBeTruthy(); const profile = (await profileResponse.json()) as UcpProfileDocument; @@ -32,16 +27,28 @@ test.describe('Path-prefixed sales channel domains @UcpKnownBlocked @UcpBlockedB 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 }) => { + 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); + }); + + test('a fresh channel on the same host advertises nothing until it is activated', async ({ TestDataService, request }) => { const freshChannelOnSameHost = await TestDataService.createStorefrontSalesChannel(); - const beforeActivation = await page.request.get(TestDataService.wellKnownUrl(freshChannelOnSameHost.url)); + const beforeActivation = await 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)); + const afterActivation = await 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([]); }); From 304d240bd41993fa88af1df96e720a37e77221f8 Mon Sep 17 00:00:00 2001 From: Dominik Grothaus Date: Tue, 20 Oct 2026 14:13:26 +0200 Subject: [PATCH 2/3] feat: warn about a UCP sales channel that only has path-prefixed domains `ucp:config:validate` now reports the warning `path_prefixed_domain` when every storefront domain of a UCP sales channel has a path. Agents only discover a business profile at `/.well-known/ucp` on the root of an origin (RFC 8615, section 3). The UCP specification requires that URL for the profile a business names in its signed webhooks. The warning links RFC 8615. It asks for a domain without a path. The changelog entries for path-prefixed domains now say that such a sales channel gets its own UCP settings. They also say that agents only find such a sales channel on a domain without a path. --- CHANGELOG.md | 2 +- CHANGELOG_de-DE.md | 2 +- .../Config/Validation/UcpConfigValidator.php | 35 +++++++++++++++++++ .../Validation/UcpConfigValidatorTest.php | 22 ++++++++++++ 4 files changed, 59 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a142b240..966b3752 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ # next version - Serve `robots.txt` again on Shopware `6.7.1.0` to `6.7.4.x` instead of answering `500`. -- Give a sales channel whose domain has a path, such as `https://shop.example/de`, its own UCP profile. The profile under that domain advertised the endpoints and capabilities of the host's root domain, so an agent that discovered the `/de` shop was sent to the root shop. The UCP routes under that domain also applied the root domain's UCP settings. Under the `strict` signature policy, correctly signed requests to such a domain are no longer refused. Storefront pages of such a domain now point their `service-meta` link at the domain's own profile. +- 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. The profile at `/de/.well-known/ucp` now describes the `/de` sales channel, and storefront pages of that domain point their `service-meta` link at it. 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. `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 40a841c0..b55af65e 100644 --- a/CHANGELOG_de-DE.md +++ b/CHANGELOG_de-DE.md @@ -1,7 +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. -- Ein Verkaufskanal, dessen Domain einen Pfad enthält, etwa `https://shop.example/de`, erhält jetzt ein eigenes UCP-Profil. Bisher führte das Profil unter dieser Domain die Endpunkte und Funktionen der Stammdomain des Hosts auf, sodass ein Agent, der den Shop unter `/de` gefunden hatte, zum Shop der Stammdomain geleitet wurde. Auch die UCP-Routen unter dieser Domain wendeten die UCP-Einstellungen der Stammdomain an. Gilt die Signaturrichtlinie `strict`, werden korrekt signierte Requests an eine solche Domain nicht mehr abgelehnt. Storefront-Seiten einer solchen Domain verweisen mit ihrem `service-meta`-Link jetzt auf das eigene Profil der Domain. +- 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. Das Profil unter `/de/.well-known/ucp` beschreibt jetzt den Verkaufskanal unter `/de`. Storefront-Seiten dieser Domain verweisen mit ihrem `service-meta`-Link jetzt auf dieses Profil. 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. `ucp:config:validate` warnt jetzt, wenn alle Domains eines für UCP freigegebenen Verkaufskanals einen Pfad enthalten. # 1.4.0 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/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); From e398a2a3f24f0906dade7167838cf3de0b15c1da Mon Sep 17 00:00:00 2001 From: Dominik Grothaus Date: Tue, 20 Oct 2026 15:21:59 +0200 Subject: [PATCH 3/3] fix: serve no UCP profile below a domain's path prefix A domain with a path, such as `https://shop.example/de`, no longer serves a UCP profile. `/de/.well-known/ucp` now answers `404`. Well-known URIs exist only at the root of an origin (RFC 8615, section 3). UCP requires a business profile URL to point to `/.well-known/ucp`. The new `UcpProfileLocator` decides where a domain's profile lives. The `service-meta` link, `api-catalog`, `ai-catalog.json`, `llms.txt`, `agents.md` and the summary of `ucp:setup` ask it. They leave the profile out for a domain with a path. Core passes a base URL only to `ai-catalog.json`, so the new Twig function `swag_agentic_commerce_ucp_profile_url` falls back to the requested domain for the other files. The acceptance specs for path-prefixed domains now expect no profile and no profile links. --- CHANGELOG.md | 2 +- CHANGELOG_de-DE.md | 2 +- README.md | 5 +- .../ApiCatalog/ApiCatalogLinksetBuilder.php | 45 +++++----- .../agentic/.well-known/ai-catalog.json.twig | 4 +- .../includes/ucp-agents.md.twig | 2 +- .../includes/ucp-llms.txt.twig | 2 +- .../agentic/.well-known/ai-catalog.json.twig | 4 +- .../UcpProfileLinkHeaderSubscriber.php | 11 +-- src/Ucp/Command/UcpSetupCommand.php | 15 +++- .../PathPrefixedProfileNotFoundListener.php | 45 ++++++++++ .../Profile/ProfileCacheHeadersListener.php | 6 +- src/Ucp/Profile/UcpProfileLocator.php | 31 +++++++ src/Ucp/Twig/UcpAgenticFilesExtension.php | 28 ++++++- tests/Unit/AgenticAiCatalogTemplateTest.php | 26 +++++- .../ApiCatalog/ApiCatalogControllerTest.php | 7 +- .../ApiCatalogLinksetBuilderTest.php | 30 ++++++- .../UcpProfileLinkHeaderSubscriberTest.php | 7 +- .../Unit/Ucp/Command/UcpSetupCommandTest.php | 2 + ...athPrefixedProfileNotFoundListenerTest.php | 84 +++++++++++++++++++ .../Ucp/Profile/UcpProfileLocatorTest.php | 33 ++++++++ tests/Unit/UcpAgenticFilesExtensionTest.php | 57 ++++++++++++- .../tests/UcpContent/discovery-files.spec.ts | 30 +++---- .../UcpProtocol/path-prefixed-domain.spec.ts | 34 ++------ 24 files changed, 410 insertions(+), 102 deletions(-) create mode 100644 src/Ucp/Profile/PathPrefixedProfileNotFoundListener.php create mode 100644 src/Ucp/Profile/UcpProfileLocator.php create mode 100644 tests/Unit/Ucp/Profile/PathPrefixedProfileNotFoundListenerTest.php create mode 100644 tests/Unit/Ucp/Profile/UcpProfileLocatorTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 966b3752..61b7007e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +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. The profile at `/de/.well-known/ucp` now describes the `/de` sales channel, and storefront pages of that domain point their `service-meta` link at it. 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. `ucp:config:validate` now warns about a UCP sales channel whose domains all have a path. +- 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 b55af65e..476cb8ec 100644 --- a/CHANGELOG_de-DE.md +++ b/CHANGELOG_de-DE.md @@ -1,7 +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. Das Profil unter `/de/.well-known/ucp` beschreibt jetzt den Verkaufskanal unter `/de`. Storefront-Seiten dieser Domain verweisen mit ihrem `service-meta`-Link jetzt auf dieses Profil. 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. `ucp:config:validate` warnt jetzt, wenn alle Domains eines für UCP freigegebenen Verkaufskanals einen Pfad enthalten. +- 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/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 4f0a04c2..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; @@ -14,11 +15,12 @@ #[Package('discovery')] final class UcpProfileLinkHeaderSubscriber implements EventSubscriberInterface { - private const UCP_PROFILE_LINK = '<%s/.well-known/ucp>; rel="service-meta"'; + private const UCP_PROFILE_LINK = '; rel="service-meta"'; public function __construct( private readonly UcpConfigService $configService, private readonly SalesChannelDomainResolver $domainResolver, + private readonly UcpProfileLocator $profileLocator, ) { } @@ -39,19 +41,18 @@ public function onBeforeSendResponse(BeforeSendResponseEvent $event): void $resolvedDomain = $this->domainResolver->resolveByRequest($event->getRequest()); if ( null === $resolvedDomain + || null === $this->profileLocator->profileUrl($resolvedDomain->baseUrl) || !$this->isUcpActiveForSalesChannel($resolvedDomain->salesChannelId) ) { return; } - $domainPath = rtrim((string) parse_url($resolvedDomain->baseUrl, \PHP_URL_PATH), '/'); - $profileLink = \sprintf(self::UCP_PROFILE_LINK, $domainPath); $response = $event->getResponse(); - if ($response->headers->contains('Link', $profileLink)) { + if ($response->headers->contains('Link', self::UCP_PROFILE_LINK)) { return; } - $response->headers->set('Link', $profileLink, false); + $response->headers->set('Link', self::UCP_PROFILE_LINK, false); } private function isHtmlResponse(BeforeSendResponseEvent $event): bool 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/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 @@ +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/Storefront/Subscriber/UcpProfileLinkHeaderSubscriberTest.php b/tests/Unit/Storefront/Subscriber/UcpProfileLinkHeaderSubscriberTest.php index 9abbb790..d2e57bd0 100644 --- a/tests/Unit/Storefront/Subscriber/UcpProfileLinkHeaderSubscriberTest.php +++ b/tests/Unit/Storefront/Subscriber/UcpProfileLinkHeaderSubscriberTest.php @@ -18,6 +18,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 Swag\AgenticCommerce\Ucp\SalesChannel\SalesChannelDomainResolver; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -65,7 +66,7 @@ public function itDoesNotAddTheLinkForAnUnknownSalesChannelDomain(): void } #[Test] - public function itAppendsTheLinkForAPathPrefixedDomainAfterCoreStrippedThePrefix(): void + public function itAddsNoLinkForAPathPrefixedDomain(): void { $htmlResponse = new Response('', Response::HTTP_OK, ['Content-Type' => 'text/html']); $requestWithStrippedPrefix = Request::create('https://shop.example/products'); @@ -74,7 +75,7 @@ public function itAppendsTheLinkForAPathPrefixedDomainAfterCoreStrippedThePrefix $this->subscriber(new UcpConfig(active: true), $this->domain('https://shop.example/de')) ->onBeforeSendResponse(new BeforeSendResponseEvent($requestWithStrippedPrefix, $htmlResponse)); - static::assertSame(['; rel="service-meta"'], $htmlResponse->headers->all('Link')); + static::assertSame([], $htmlResponse->headers->all('Link')); } private function subscriber( @@ -94,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 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/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/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 index 4a493895..f59dbdaf 100644 --- a/tests/acceptance/tests/UcpProtocol/path-prefixed-domain.spec.ts +++ b/tests/acceptance/tests/UcpProtocol/path-prefixed-domain.spec.ts @@ -1,30 +1,22 @@ import { expect, test } from '@fixtures/AcceptanceTest'; -import type { UcpProfileDocument } from '@services/UcpTestDataService'; test.describe('Path-prefixed sales channel domains @UcpProtocol', () => { - test('the worker channel publishes its own profile under its path prefix', async ({ TestDataService, DefaultSalesChannel, request }) => { + 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.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); - } + + expect(profileResponse.status(), await profileResponse.text()).toBe(404); }); - test('storefront pages point their service-meta link at the channel\'s own profile', async ({ TestDataService, request }) => { + 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); - 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)); + 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 }) => { @@ -38,18 +30,4 @@ test.describe('Path-prefixed sales channel domains @UcpProtocol', () => { const tamperedSignatureRefusal = await UcpAgent.at(strictChannel.url).tampered({ tamperSignatureBytes: true }).send('GET', unknownCartPath); expect(tamperedSignatureRefusal.status(), await tamperedSignatureRefusal.text()).toBe(401); }); - - test('a fresh channel on the same host advertises nothing until it is activated', async ({ TestDataService, request }) => { - const freshChannelOnSameHost = await TestDataService.createStorefrontSalesChannel(); - - const beforeActivation = await 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 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([]); - }); });