diff --git a/tests/acceptance/README.md b/tests/acceptance/README.md index 393f6c6..88d79fe 100644 --- a/tests/acceptance/README.md +++ b/tests/acceptance/README.md @@ -45,17 +45,17 @@ with an opaque error. Projects are routed by tag, the way core does it. A spec opts into a project by carrying its tag in the title. -| Project | Tag | Notes | -| ----------------- | ------------------ | -------------------------------------------------------------------- | -| `Signer` | `@Signer` | RFC 9421 signer proofs. No browser. Every UCP project depends on it. | -| `Setup` | `@Setup` | The known-blockers guard and the bootstrap check. | -| `UcpProtocol` | `@UcpProtocol` | UCP transport journeys. | -| `UcpContent` | `@UcpContent` | Product feed, tracking, discovery files. | -| `UcpEmbedded` | `@UcpEmbedded` | Embedded transport. | -| `UcpAdmin` | `@UcpAdmin` | Administration UI. | -| `UcpAcl` | `@UcpAcl` | ACL matrix. | -| `UcpSerial` | `@UcpSerial` | Runs with `workers: 1` for specs that cannot be parallelised. | -| `UcpKnownBlocked` | `@UcpKnownBlocked` | **Non-gating.** See below. | +| Project | Tag | Notes | +| ----------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------- | +| `Signer` | `@Signer` | RFC 9421 signer proofs. No browser. Every UCP project depends on it. | +| `Setup` | `@Setup` | The known-blockers guard, the bootstrap check and the fixture proofs. `@UcpConsole` marks the one spec that needs `bin/console`. | +| `UcpProtocol` | `@UcpProtocol` | UCP transport journeys. | +| `UcpContent` | `@UcpContent` | Product feed, tracking, discovery files. | +| `UcpEmbedded` | `@UcpEmbedded` | Embedded transport. | +| `UcpAdmin` | `@UcpAdmin` | Administration UI. | +| `UcpAcl` | `@UcpAcl` | ACL matrix. | +| `UcpSerial` | `@UcpSerial` | Runs with `workers: 1` for specs that cannot be parallelised. | +| `UcpKnownBlocked` | `@UcpKnownBlocked` | **Non-gating.** See below. | Most projects match no specs yet; their issues add them. A project with no matching spec reports zero tests and passes. @@ -101,6 +101,70 @@ import { expect, test } from "@fixtures/AcceptanceTest"; `fixtures/AcceptanceTest.ts` calls `mergeTests(ShopwareTestSuite, ...)` and re-exports the package, so a spec never has to know which file a fixture came from. +## Fixtures + +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), so specs read a channel's profile through the Admin API preview until that is fixed. The plugin +fixtures build on the worker channel. + +**`TestDataService`** (test scope) is `UcpTestDataService`, the ATS `TestDataService` plus UCP: +`createStorefrontSalesChannel()`, `createHeadlessSalesChannel()`, `createFeedSalesChannel()`, +`activateUcp()`, `saveUcpConfig()`, `getUcpConfig()`, `getProfilePreview()` and +`listUcpSalesChannels()`. `activateUcp()` enables every capability and the REST transport and +allowlists the agent profile host on all three per-channel lists, because the SDK falls back to the +shop's own host for an empty list and would refuse a `localhost` profile. Its cleanup runs before +the ATS registry's. It restores the UCP config each surviving channel had before the first write, +deletes the signing keys of the channels it created and activated when `UcpConsole` reaches +`bin/console`, and deletes the channels it created. Every step runs even when an earlier one fails, +and the failures are reported together. + +**`UcpAgentProfileHost`** (worker scope) has `publish()`, which generates an ES256 key pair and +writes the agent's profile, public key included, to +`/public/ucp-acceptance-agents/.json`. The profile carries every shopping +capability the UCP specification defines at the protocol version, so the SDK has something to +negotiate, and `publish({ capabilities })` replaces that set for negative specs. The shop fetches +the profile from `UCP_AGENT_PROFILE_BASE_URL` (default `http://localhost:8000`), the web container's +own document root, the only plain-http host the SDK admits and only in development mode. The fixture +verifies the file is served through `APP_URL` and throws otherwise. It never falls back to the +shop's own profile. A worker removes its own files at teardown. + +**`UcpAclUsers`** (test scope) has `as('ucp.viewer' | 'ucp.editor' | 'ucp.key_rotator')`, which +creates an ACL role and a non-admin user, logs into the Administration in a separate page context +and returns it. The privilege sets are read from +`src/Resources/app/administration/src/extension/sw-sales-channel/acl/index.js`, with core's +`sales_channel.viewer` set added so the user can reach the sales channel at all. + +**`UcpConsole`** (worker scope) runs `ucp:signing-keys:{generate,list,show-public,retire,delete}`, +the only signing-key management surface, through the lane's `bin/console`. Nothing else passes its +allow-list. Before the first command it probes the real prefix once. When that probe fails, the key +cleanup is skipped with a warning naming the sales channels whose keys stay behind, and the +`@UcpConsole` spec fails. + +The lane has to run with `SWAG_AGENTIC_COMMERCE_UCP_PROFILE_FETCHING_DEVELOPMENT_MODE=1` for the +shop to fetch a test agent's profile from `localhost` over plain http. + +### What the fixtures need from their host + +Beyond `APP_URL`, two fixtures touch the Shopware project directly. Each resolves what it needs from +an environment variable when it is first used, and fails loudly when it cannot: + +| Variable | Default | Needed by | +| -------------- | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | +| `SHOPWARE_DIR` | the nearest ancestor of this directory with a `bin/console` | `UcpAgentProfileHost` writes into its `public/`; `UcpConsole` runs there | +| `PLUGIN_DIR` | the checkout this suite sits in, else the `custom/plugins` directory that holds `shopware/agentic-commerce` | reading `UcpProtocol::VERSION` and the Administration ACL file | +| `UCP_CONSOLE` | `docker compose exec -T web php bin/console` | `UcpConsole` | + +A runner that reaches Shopware only over HTTP can run every spec that uses neither fixture. The +profile host and the console need the project mounted. + +`UCP_CONSOLE` is executed directly, not through a shell, so it cannot see shell aliases. On a +machine where `docker` is only an alias for `podman`, the default prefix is not found and the +signing keys of created channels are left behind. Set `UCP_CONSOLE` to a command that reaches the +web container without an alias. + ## Environment Read by the ATS itself: `APP_URL`, `ADMIN_API_URL`, `ADMIN_URL`, `SHOPWARE_ACCESS_KEY_ID`, @@ -110,6 +174,9 @@ Read by the ATS itself: `APP_URL`, `ADMIN_API_URL`, `ADMIN_URL`, `SHOPWARE_ACCES Read by this config: `SHOPWARE_PLAYWRIGHT_IGNORE_HTTPS_ERRORS`, `DATABASE_URL`, `CI`. +Read by the plugin fixtures: `SHOPWARE_DIR`, `PLUGIN_DIR`, `UCP_AGENT_PROFILE_BASE_URL`, +`UCP_CONSOLE`, `UCP_CONSOLE_TIMEOUT_MS`, `ATS_SKIP_CLEANUP`. + > `DATABASE_URL` is parsed into `ATS_DATABASE_USERNAME`/`_PASSWORD`/`_HOST`/`_NAME` for parity with > core's configuration, but **nothing consumes those variables today**. ATS 12.20.0 ships no > database driver and reaches Shopware only over the Admin API, the Store API and Mailpit. Do not go @@ -125,6 +192,11 @@ reuse the same records rather than accumulating new ones. Expect one ` accep channel per parallel worker to remain in the shop after a run. Changing `ATS_ID_SEED` produces a new set. +Activating UCP on a channel auto-provisions a signing key in the SDK's key store, and on a Shopware +that ships the sales-channel file subsystem it also enables the agentic files for that channel. The +worker channel keeps both. Signing keys of the channels the suite created are removed only when +`UcpConsole` can reach `bin/console`; otherwise they outlive their channel. + ## Notes - `npm install` warns that `skia-canvas` has an install script npm 11 does not run by default. It is diff --git a/tests/acceptance/fixtures/AcceptanceTest.ts b/tests/acceptance/fixtures/AcceptanceTest.ts index da010cb..f6aa866 100644 --- a/tests/acceptance/fixtures/AcceptanceTest.ts +++ b/tests/acceptance/fixtures/AcceptanceTest.ts @@ -1,5 +1,26 @@ import { test as ShopwareTestSuite, mergeTests } from '@shopware-ag/acceptance-test-suite'; +import type { FixtureTypes as BaseTypes } from '@shopware-ag/acceptance-test-suite'; +import { test as ucpConsole } from './UcpConsole'; +import { test as ucpTestData } from './UcpTestData'; +import { test as ucpAgentProfileHost } from './UcpAgentProfileHost'; +import { test as ucpAclUsers } from './UcpAclUsers'; +import type { UcpConsoleTypes } from './UcpConsole'; +import type { UcpTestDataFixtureTypes } from './UcpTestData'; +import type { UcpAgentProfileHostTypes } from './UcpAgentProfileHost'; +import type { UcpAclUsersTypes } from './UcpAclUsers'; export * from '@shopware-ag/acceptance-test-suite'; -export const test = mergeTests(ShopwareTestSuite); +export type FixtureTypes = Omit + & UcpTestDataFixtureTypes + & UcpConsoleTypes + & UcpAgentProfileHostTypes + & UcpAclUsersTypes; + +export const test = mergeTests( + ShopwareTestSuite, + ucpConsole, + ucpTestData, + ucpAgentProfileHost, + ucpAclUsers, +); diff --git a/tests/acceptance/fixtures/UcpAclUsers.ts b/tests/acceptance/fixtures/UcpAclUsers.ts new file mode 100644 index 0000000..0531acb --- /dev/null +++ b/tests/acceptance/fixtures/UcpAclUsers.ts @@ -0,0 +1,113 @@ +import { test as base } from '@playwright/test'; +import type { Page } from '@playwright/test'; +import { createNewAdminPageContext, loginToAdministration } from '@shopware-ag/acceptance-test-suite'; +import type { FixtureTypes, User } from '@shopware-ag/acceptance-test-suite'; +import { readAdminPrivilegeMapping, resolveRole } from '@services/pluginSource'; +import type { UcpTestDataFixtureTypes } from './UcpTestData'; + +export const UCP_ROLES = ['ucp.viewer', 'ucp.editor', 'ucp.key_rotator'] as const; +export type UcpRole = typeof UCP_ROLES[number]; + +/** + * Core's `sales_channel.viewer` privileges (`sw-sales-channel/acl/index.js` on trunk), flattened. + * Without them a user cannot open the sales channel the Agentic Commerce tab sits on. + */ +const SALES_CHANNEL_VIEWER_PRIVILEGES = [ + 'sales_channel:read', + 'sales_channel_type:read', + 'payment_method:read', + 'shipping_method:read', + 'country:read', + 'currency:read', + 'sales_channel_domain:read', + 'sales_channel_file:read', + 'snippet_set:read', + 'sales_channel_analytics:read', + 'product_export:read', + 'theme:read', + 'custom_field_set:read', + 'custom_field:read', + 'custom_field_set_relation:read', + 'category:read', + 'customer_group:read', + 'media:read', + 'media_folder:read', + 'media_default_folder:read', + 'product:read', + 'product_stream:read', + 'product_visibility:read', + 'property_group:read', + 'property_group_option:read', + 'user_config:read', + 'user_config:create', + 'user_config:update', + 'system_config:read', + 'sales_channel_tracking_order:read', + 'sales_channel_tracking_customer:read', + 'order:read', + 'order_transaction:read', + 'state_machine_state:read', +]; + +export interface UcpAclUser { + role: UcpRole + user: User + privileges: string[] + page: Page +} + +export interface UcpAclUsers { + /** One Administration user per UCP role, created and logged in on first use. */ + as(role: UcpRole): Promise +} + +export interface UcpAclUsersTypes { + UcpAclUsers: UcpAclUsers +} + +export const test = base.extend({ + UcpAclUsers: async ({ TestDataService, AdminApiContext, SalesChannelBaseConfig, browser }, use) => { + const mapping = await readAdminPrivilegeMapping(); + const users = new Map>(); + + const create = async (role: UcpRole): Promise => { + const resolved = resolveRole(mapping, role); + const privileges = [...new Set([ + ...(TestDataService.getBasicAclRoleStruct().privileges ?? []), + ...SALES_CHANNEL_VIEWER_PRIVILEGES, + 'sales_channel.viewer', + ...resolved.keys, + ...resolved.privileges, + ])]; + + const aclRole = await TestDataService.createAclRole({ name: `${TestDataService.namePrefix}${role}-${aclRoleSuffix()}`, privileges }); + const user = await TestDataService.createUserRetryingTokenConflicts({ admin: false }); + await TestDataService.assignAclRoleUser(aclRole.id, user.id); + + const page = await loginToAdministration(await createNewAdminPageContext(browser, SalesChannelBaseConfig), user, AdminApiContext); + + return { role, user, privileges, page }; + }; + + await use({ + as: (role) => { + let pending = users.get(role); + if (pending === undefined) { + pending = create(role); + users.set(role, pending); + } + + return pending; + }, + }); + + for (const pending of users.values()) { + const { page } = await pending.catch(() => ({ page: null })); + await page?.context().close(); + } + }, +}); + +function aclRoleSuffix(): string { + return Math.random().toString(36).slice(2, 8); +} diff --git a/tests/acceptance/fixtures/UcpAgentProfileHost.ts b/tests/acceptance/fixtures/UcpAgentProfileHost.ts new file mode 100644 index 0000000..cdc9477 --- /dev/null +++ b/tests/acceptance/fixtures/UcpAgentProfileHost.ts @@ -0,0 +1,183 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { request, test as base } from '@playwright/test'; +import { readUcpProtocolVersion, resolveShopwareDir } from '@services/pluginSource'; + +export const AGENT_PROFILE_DIRECTORY = 'ucp-acceptance-agents'; +const DEFAULT_AGENT_PROFILE_BASE_URL = 'http://localhost:8000'; + +export interface PublicSigningKeyJwk { + kid: string + kty: 'EC' + alg: 'ES256' + use: 'sig' + crv: 'P-256' + x: string + y: string +} + +export interface TestAgent { + kid: string + label: string + /** Where the shop fetches the profile from; a `localhost` URL as seen from the web container. */ + profileUrl: string + agentHeader: string + publicJwk: PublicSigningKeyJwk + /** PKCS#8 PEM, for signing requests as this agent. */ + privateKeyPem: string + profile: Record +} + +export interface CapabilityEntry { + version: string + spec: string + schema: string + extends?: string[] +} + +export type CapabilityMap = Record; + +export interface PublishOptions { + kid?: string + label?: string + /** Replaces the default set entirely; `{}` publishes an agent that can negotiate nothing. */ + capabilities?: CapabilityMap +} + +export interface UcpAgentProfileHost { + host: string + directory: string + publish(options?: PublishOptions): Promise +} + +const SHOPPING_CAPABILITIES: { name: string, document: string, extends?: string[] }[] = [ + { name: 'dev.ucp.shopping.catalog.search', document: 'catalog' }, + { name: 'dev.ucp.shopping.catalog.lookup', document: 'catalog' }, + { name: 'dev.ucp.shopping.cart', document: 'cart' }, + { name: 'dev.ucp.shopping.checkout', document: 'checkout' }, + { name: 'dev.ucp.shopping.discount', document: 'discount', extends: ['dev.ucp.shopping.cart', 'dev.ucp.shopping.checkout'] }, + { name: 'dev.ucp.shopping.order', document: 'order' }, +]; + +/** + * Every shopping capability the UCP specification defines, at the given protocol version. The SDK + * negotiates on name, version and `extends`, so an agent that publishes none gets + * `capabilities_incompatible` on every operation. + */ +export function specShoppingCapabilities(version: string): CapabilityMap { + return Object.fromEntries(SHOPPING_CAPABILITIES.map(capability => [capability.name, [{ + version, + spec: `https://ucp.dev/specification/${capability.document}/`, + schema: `https://ucp.dev/${version}/schemas/shopping/${capability.document}.json`, + ...(capability.extends === undefined ? {} : { extends: capability.extends }), + }]])); +} + +export interface UcpAgentProfileHostTypes { + UcpAgentProfileHost: UcpAgentProfileHost +} + +function agentProfileBaseUrl(): URL { + return new URL((process.env.UCP_AGENT_PROFILE_BASE_URL ?? DEFAULT_AGENT_PROFILE_BASE_URL).replace(/\/+$/, '') + '/'); +} + +export function agentProfileHost(): string { + return agentProfileBaseUrl().hostname; +} + +function generateSigningKey(kid: string): { publicJwk: PublicSigningKeyJwk, privateKeyPem: string } { + const { publicKey, privateKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' }); + const jwk = publicKey.export({ format: 'jwk' }) as { x: string, y: string }; + + return { + publicJwk: { kid, kty: 'EC', alg: 'ES256', use: 'sig', crv: 'P-256', x: jwk.x, y: jwk.y }, + privateKeyPem: privateKey.export({ format: 'pem', type: 'pkcs8' }).toString(), + }; +} + +/** + * Publishes test agents' profiles (the JSON document carrying their public signing keys) through + * the shop's own `public/` directory, so the web container fetches them from `localhost`, the + * only plain-http host the SDK's URL-safety rules admit, and only in development mode. + * + * Never falls back to the shop's own profile: an agent that is the shop proves nothing. + */ +export const test = base.extend, UcpAgentProfileHostTypes>({ + UcpAgentProfileHost: [ + async ({}, use, workerInfo) => { + const directory = path.join(resolveShopwareDir(), 'public', AGENT_PROFILE_DIRECTORY); + const baseUrl = agentProfileBaseUrl(); + const version = readUcpProtocolVersion(); + const written: string[] = []; + + fs.mkdirSync(directory, { recursive: true }); + fs.accessSync(directory, fs.constants.W_OK); + + const publish: UcpAgentProfileHost['publish'] = async ({ kid, label, capabilities } = {}) => { + const agentKid = kid ?? `acceptance-w${workerInfo.parallelIndex}-${crypto.randomUUID().slice(0, 8)}`; + const agentLabel = label ?? 'shopware-acceptance-agent'; + const { publicJwk, privateKeyPem } = generateSigningKey(agentKid); + const profile = { + ucp: { + version, + services: {}, + capabilities: capabilities ?? specShoppingCapabilities(version), + payment_handlers: {}, + }, + signing_keys: [publicJwk], + }; + const file = path.join(directory, `${agentKid}.json`); + const profileUrl = new URL(`${AGENT_PROFILE_DIRECTORY}/${agentKid}.json`, baseUrl).toString(); + + fs.mkdirSync(directory, { recursive: true }); + fs.writeFileSync(file, JSON.stringify(profile, null, 2)); + written.push(file); + + await assertServedByTheShop(agentKid, profileUrl); + + return { + kid: agentKid, + label: agentLabel, + profileUrl, + agentHeader: `${agentLabel}; profile="${profileUrl}"`, + publicJwk, + privateKeyPem, + profile, + }; + }; + + await use({ host: baseUrl.hostname, directory, publish }); + + // Only this worker's files. The directory is shared by every worker, and one that + // finishes first would delete it under another's write. + for (const file of written) { + fs.rmSync(file, { force: true }); + } + }, + { scope: 'worker' }, + ], +}); + +/** + * The file has to reach the shop's web server through the bind mount. Checked over `APP_URL`, + * the same document root the web container serves on `localhost`. + */ +async function assertServedByTheShop(kid: string, profileUrl: string): Promise { + const appUrl = (process.env.APP_URL ?? '').replace(/\/+$/, '') + '/'; + const publicUrl = new URL(`${AGENT_PROFILE_DIRECTORY}/${kid}.json`, appUrl).toString(); + const context = await request.newContext({ ignoreHTTPSErrors: true }); + + try { + const response = await context.get(publicUrl); + if (!response.ok()) { + throw new Error( + `The agent profile written for the shop is not served: GET ${publicUrl} answered ${response.status()}. ` + + `The web container must serve /public through its bind mount, so that ${profileUrl} resolves inside it.`, + ); + } + } + finally { + await context.dispose(); + } +} diff --git a/tests/acceptance/fixtures/UcpConsole.ts b/tests/acceptance/fixtures/UcpConsole.ts new file mode 100644 index 0000000..5e8ea92 --- /dev/null +++ b/tests/acceptance/fixtures/UcpConsole.ts @@ -0,0 +1,15 @@ +import { test as base } from '@playwright/test'; +import { UcpConsole } from '@services/UcpConsole'; + +export interface UcpConsoleTypes { + UcpConsole: UcpConsole +} + +export const test = base.extend, UcpConsoleTypes>({ + UcpConsole: [ + async ({}, use) => { + await use(UcpConsole.fromEnvironment()); + }, + { scope: 'worker' }, + ], +}); diff --git a/tests/acceptance/fixtures/UcpTestData.ts b/tests/acceptance/fixtures/UcpTestData.ts new file mode 100644 index 0000000..181ec38 --- /dev/null +++ b/tests/acceptance/fixtures/UcpTestData.ts @@ -0,0 +1,57 @@ +import { test as base } from '@playwright/test'; +import type { FixtureTypes } from '@shopware-ag/acceptance-test-suite'; +import { UcpTestDataService } from '@services/UcpTestDataService'; +import { agentProfileHost } from './UcpAgentProfileHost'; +import type { UcpConsoleTypes } from './UcpConsole'; + +export interface UcpTestDataFixtureTypes { + TestDataService: UcpTestDataService +} + +const skipCleanUp = ['1', 'true'].includes(process.env.ATS_SKIP_CLEANUP ?? ''); + +/** + * Replaces the ATS `TestDataService` with the UCP-aware subclass, cleaned up in the same order + * SwagCommercial uses: plugin entities first, then the ATS registry. + */ +export const test = base.extend({ + TestDataService: async ({ AdminApiContext, IdProvider, DefaultSalesChannel, SalesChannelBaseConfig, UcpConsole }, use) => { + const appUrl = SalesChannelBaseConfig.appUrl ?? process.env.APP_URL ?? ''; + const service = new UcpTestDataService(AdminApiContext, IdProvider, { + defaultSalesChannel: DefaultSalesChannel.salesChannel, + defaultTaxId: SalesChannelBaseConfig.taxId, + defaultCurrencyId: SalesChannelBaseConfig.defaultCurrencyId, + defaultCategoryId: DefaultSalesChannel.salesChannel.navigationCategoryId, + defaultLanguageId: DefaultSalesChannel.salesChannel.languageId, + defaultCountryId: DefaultSalesChannel.salesChannel.countryId, + defaultCustomerGroupId: DefaultSalesChannel.salesChannel.customerGroupId, + appUrl: appUrl.replace(/\/+$/, '') + '/', + baseConfig: SalesChannelBaseConfig, + agentHost: agentProfileHost(), + console: UcpConsole, + }); + + await use(service); + + if (skipCleanUp) { + return; + } + + // The ATS cleanup always runs, and neither failure may replace the other in the report. + const cleanupErrors: unknown[] = []; + for (const cleanup of [() => service.cleanUpUcpEntities(), () => service.cleanUp()]) { + try { + await cleanup(); + } + catch (error) { + cleanupErrors.push(error); + } + } + if (cleanupErrors.length === 1) { + throw cleanupErrors[0]; + } + if (cleanupErrors.length > 1) { + throw new Error(cleanupErrors.map(error => (error instanceof Error ? error.message : String(error))).join('\n\nthen:\n')); + } + }, +}); diff --git a/tests/acceptance/known-blockers.ts b/tests/acceptance/known-blockers.ts index 3e028d2..00daf1c 100644 --- a/tests/acceptance/known-blockers.ts +++ b/tests/acceptance/known-blockers.ts @@ -15,7 +15,7 @@ export interface KnownBlocker { export const knownBlockers: KnownBlocker[] = [ { key: 'D1', - description: 'The SDK signs and verifies over @method, @target-uri and content-digest and rejects a signature without created or expires. The spec covers @method, @authority and @path, makes created optional and has no expires, so a spec-conformant agent is rejected under the strict signature policy.', + description: 'SDK verification requires created, which the spec makes optional, so a spec-conformant signature without it is rejected under the strict signature policy. The SDK\'s own signing side still covers @method and @target-uri and always adds expires and alg. Its verifier already rebuilds the base from the components the client declares, @authority and @path included.', owner: 'dgrothaus-sw', issueUrl: 'https://github.com/shopware/agentic-commerce/issues/187', reviewBy: '2026-12-31', @@ -43,7 +43,7 @@ export const knownBlockers: KnownBlocker[] = [ }, { key: 'D5', - description: 'The MCP tools are named shopware-ucp-* and take one JSON string as payload; the spec names them create_checkout, get_checkout and so on and carries ucp-agent and idempotency-key per call in meta.', + description: 'ucp-agent and idempotency-key are read from the MCP transport\'s HTTP request headers, not from meta on each call as the spec carries them, so a spec-conformant MCP client is rejected with "$.headers.idempotency-key is required". The tool names and the structured object payload were fixed in PR #224.', owner: 'dgrothaus-sw', issueUrl: 'https://github.com/shopware/agentic-commerce/issues/187', reviewBy: '2026-12-31', @@ -62,4 +62,11 @@ 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', + }, ]; diff --git a/tests/acceptance/package.json b/tests/acceptance/package.json index 396919f..91f7aa4 100644 --- a/tests/acceptance/package.json +++ b/tests/acceptance/package.json @@ -12,7 +12,7 @@ "test": "playwright test --grep-invert @UcpKnownBlocked", "test:all": "playwright test", "test:known-blocked": "playwright test --project=UcpKnownBlocked", - "lint": "eslint fixtures tests known-blockers.ts playwright.config.ts eslint.config.ts", + "lint": "eslint fixtures services tests known-blockers.ts playwright.config.ts eslint.config.ts", "lint:fix": "npm run lint -- --fix", "typecheck": "tsc --noEmit" }, diff --git a/tests/acceptance/services/UcpConsole.ts b/tests/acceptance/services/UcpConsole.ts new file mode 100644 index 0000000..34c841f --- /dev/null +++ b/tests/acceptance/services/UcpConsole.ts @@ -0,0 +1,166 @@ +import { spawnSync } from 'node:child_process'; +import { resolveShopwareDir } from './pluginSource'; + +const ALLOWED_COMMANDS = /^ucp:signing-keys:(generate|list|show-public|retire|delete)$/; +const DEFAULT_COMMAND = ['docker', 'compose', 'exec', '-T', 'web', 'php', 'bin/console']; +const DEFAULT_TIMEOUT_MS = 60_000; + +export interface ConsoleResult { + argv: string[] + status: number | null + stdout: string + stderr: string +} + +export interface SigningKeySummary { + kid: string + algorithm?: string + status?: string + [field: string]: unknown +} + +export class ConsoleUnavailableError extends Error { +} + +/** + * Runs the plugin's `ucp:signing-keys:*` commands, the only signing-key management surface, + * through the lane's `bin/console`. + * + * The command prefix comes from `UCP_CONSOLE` (whitespace-separated), default + * `docker compose exec -T web php bin/console`, executed in the Shopware project directory. + * Nothing else is allowed through here. + */ +export class UcpConsole { + private availability: boolean | null = null; + private resolvedShopwareDir: string | null = null; + + /** + * @param shopwareDir resolved on first use, so a runner that reaches the shop only over HTTP + * can use every fixture that never runs a command + */ + constructor( + private readonly shopwareDir: () => string, + private readonly commandPrefix: string[], + private readonly timeoutMs = DEFAULT_TIMEOUT_MS, + ) { + } + + static fromEnvironment(): UcpConsole { + const prefix = (process.env.UCP_CONSOLE ?? '').trim(); + + return new UcpConsole( + resolveShopwareDir, + prefix === '' ? DEFAULT_COMMAND : prefix.split(/\s+/), + Number(process.env.UCP_CONSOLE_TIMEOUT_MS ?? DEFAULT_TIMEOUT_MS), + ); + } + + describe(): string { + let directory: string; + try { + directory = this.projectDir(); + } + catch { + directory = 'no Shopware project found'; + } + + return `${this.commandPrefix.join(' ')} (in ${directory})`; + } + + /** + * Whether the configured prefix reaches the lane's `bin/console`. Probes with the real + * command, since a binary that exists can still target a wrong or stopped container. + * Cached per instance. + */ + isAvailable(): boolean { + if (this.availability === null) { + try { + this.availability = this.spawn('ucp:signing-keys:list', ['--help']).status === 0; + } + catch { + this.availability = false; + } + } + + return this.availability; + } + + private projectDir(): string { + this.resolvedShopwareDir ??= this.shopwareDir(); + + return this.resolvedShopwareDir; + } + + private spawn(command: string, args: string[]) { + const [executable, ...prefixArgs] = this.commandPrefix; + const argv = [executable, ...prefixArgs, command, ...args, '--no-interaction']; + const consoleProcess = spawnSync(executable, argv.slice(1), { + cwd: this.projectDir(), + encoding: 'utf8', + timeout: this.timeoutMs, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + return { argv, ...consoleProcess }; + } + + run(command: string, args: string[] = []): ConsoleResult { + if (!ALLOWED_COMMANDS.test(command)) { + throw new Error(`UcpConsole runs ucp:signing-keys:* only, not "${command}".`); + } + if (!this.isAvailable()) { + throw new ConsoleUnavailableError( + `"${this.commandPrefix[0]}" is not runnable here. Set UCP_CONSOLE to a command that reaches the lane's bin/console (current: ${this.describe()}).`, + ); + } + + const { argv, ...consoleProcess } = this.spawn(command, args); + + if (consoleProcess.error) { + throw new Error(`${argv.join(' ')} failed to start: ${consoleProcess.error.message}`); + } + if (consoleProcess.status !== 0) { + throw new Error(`${argv.join(' ')} exited with ${consoleProcess.status}\n${consoleProcess.stderr}${consoleProcess.stdout}`); + } + + return { argv, status: consoleProcess.status, stdout: consoleProcess.stdout, stderr: consoleProcess.stderr }; + } + + generateSigningKey(salesChannelId: string, kid: string, algorithm = 'ES256'): ConsoleResult { + return this.run('ucp:signing-keys:generate', [ + `--sales-channel=${salesChannelId}`, + `--kid=${kid}`, + `--algorithm=${algorithm}`, + ]); + } + + listSigningKeys(salesChannelId: string): SigningKeySummary[] { + const { stdout } = this.run('ucp:signing-keys:list', [`--sales-channel=${salesChannelId}`]); + + return parseJsonOutput(stdout, 'ucp:signing-keys:list'); + } + + showPublicSigningKeys(salesChannelId: string): unknown { + const { stdout } = this.run('ucp:signing-keys:show-public', [`--sales-channel=${salesChannelId}`]); + + return parseJsonOutput(stdout, 'ucp:signing-keys:show-public'); + } + + retireSigningKey(salesChannelId: string, kid: string): ConsoleResult { + return this.run('ucp:signing-keys:retire', [`--sales-channel=${salesChannelId}`, `--kid=${kid}`]); + } + + deleteSigningKey(salesChannelId: string, kid: string): ConsoleResult { + return this.run('ucp:signing-keys:delete', [`--sales-channel=${salesChannelId}`, `--kid=${kid}`]); + } +} + +/** The commands print JSON after any Symfony deprecation or profiler noise; parse from the first bracket. */ +function parseJsonOutput(stdout: string, command: string): T { + const start = stdout.search(/[[{]/); + if (start === -1) { + throw new Error(`${command} printed no JSON:\n${stdout}`); + } + + return JSON.parse(stdout.slice(start)) as T; +} diff --git a/tests/acceptance/services/UcpTestDataService.ts b/tests/acceptance/services/UcpTestDataService.ts new file mode 100644 index 0000000..ff478ec --- /dev/null +++ b/tests/acceptance/services/UcpTestDataService.ts @@ -0,0 +1,330 @@ +import { expect } from '@playwright/test'; +import { TestDataService } from '@shopware-ag/acceptance-test-suite'; +import type { DataServiceOptions, FixtureTypes, SalesChannel, User } from '@shopware-ag/acceptance-test-suite'; +import type { UcpConsole } from './UcpConsole'; + +/** Core's headless (API) sales channel type, `Defaults::SALES_CHANNEL_TYPE_API`. */ +export const HEADLESS_SALES_CHANNEL_TYPE_ID = 'f183ee5650cf4bdb8a774337575067a6'; +/** The plugin's product-feed type, `SwagAgenticCommerce::SALES_CHANNEL_TYPE_AGENTIC_COMMERCE`. */ +export const FEED_SALES_CHANNEL_TYPE_ID = '5e29f9890c4d4d519a1c7f9d5c24b7c1'; + +export const UCP_SALES_CHANNEL_TYPE_NOT_SUPPORTED = 'SWAG_AGENTIC_COMMERCE__UCP_SALES_CHANNEL_TYPE_NOT_SUPPORTED'; + +/** Mirrors `UcpConfig::toArray()`. */ +export interface UcpConfigPayload { + active: boolean + profileDomain: string | null + enabledCapabilities: string[] + enabledTransports: string[] + continueUrlTemplate: string | null + platformAllowlist: string[] + remoteProfileAllowlist: string[] + agentAllowlist: string[] + embeddedAllowedOrigins: string[] + embeddedFrameAncestors: string[] + discoveryBudget: number + catalogResultLimit: number + webhookUrlOverride: string | null + signaturePolicy: string + idempotencyRequired: boolean +} + +export const ALL_UCP_CAPABILITIES = ['catalog', 'cart', 'discount', 'checkout', 'order']; + +const USER_CREATION_ATTEMPTS = 3; + +export interface UcpSalesChannel { + salesChannel: SalesChannel + /** Path-prefixed base URL of the channel's only domain, trailing slash included. */ + url: string +} + +export interface UcpSalesChannelListEntry { + id: string + name: string | null + typeId: string + transactional: boolean + domains: { id: string, url: string }[] + ucp: Record +} + +interface ApiErrorBody { + errors?: { code?: string, detail?: string }[] +} + +/** The `/.well-known/ucp` document, as far as the specs read it. */ +export interface UcpProfileDocument { + ucp: { + version: string + services: Record + capabilities: Record + } + signing_keys: { kid: string, alg: string, crv: string }[] +} + +export interface UcpDataServiceOptions extends DataServiceOptions { + appUrl: string + baseConfig: FixtureTypes['SalesChannelBaseConfig'] + /** Host the shop fetches agent profiles from, allowlisted on every channel this service activates. */ + agentHost: string + console?: UcpConsole +} + +export class UcpTestDataService extends TestDataService { + public readonly namePrefix: string = 'Test-'; + public readonly nameSuffix: string = ''; + + private readonly appUrl: string; + private readonly baseConfig: FixtureTypes['SalesChannelBaseConfig']; + private readonly agentHost: string; + private readonly console?: UcpConsole; + + private readonly createdSalesChannelIds: string[] = []; + /** Activation provisions a signing key, so only these channels have keys to delete. */ + private readonly activatedSalesChannelIds = new Set(); + private readonly originalConfigBySalesChannelId = new Map(); + + constructor(AdminApiClient: FixtureTypes['AdminApiContext'], IdProvider: FixtureTypes['IdProvider'], options: UcpDataServiceOptions) { + super(AdminApiClient, IdProvider, options); + + this.appUrl = options.appUrl; + this.baseConfig = options.baseConfig; + this.agentHost = options.agentHost; + this.console = options.console; + } + + wellKnownUrl(baseUrl: string): string { + return `${baseUrl.replace(/\/+$/, '')}/.well-known/ucp`; + } + + async createStorefrontSalesChannel(overrides: Partial = {}): Promise { + return this.createUcpSalesChannel(this.baseConfig.storefrontTypeId, overrides); + } + + async createHeadlessSalesChannel(overrides: Partial = {}): Promise { + return this.createUcpSalesChannel(HEADLESS_SALES_CHANNEL_TYPE_ID, overrides); + } + + async createFeedSalesChannel(overrides: Partial = {}): Promise { + return this.createUcpSalesChannel(FEED_SALES_CHANNEL_TYPE_ID, overrides); + } + + async createUcpSalesChannel(typeId: string, overrides: Partial = {}): Promise { + const { id, uuid } = this.IdProvider.getIdPair(); + const { uuid: rootCategoryId } = this.IdProvider.getIdPair(); + const { uuid: customerGroupId } = this.IdProvider.getIdPair(); + const { uuid: domainId } = this.IdProvider.getIdPair(); + const url = `${this.appUrl}test-${uuid}/`; + + const response = await this.AdminApiClient.post('./_action/sync', { + data: { + 'write-sales-channel': { + entity: 'sales_channel', + action: 'upsert', + payload: [{ + id: uuid, + name: `${this.namePrefix}UCP-${id}${this.nameSuffix}`, + typeId, + languageId: this.baseConfig.currentLanguageId, + currencyId: this.baseConfig.currentCurrencyId, + paymentMethodId: this.baseConfig.invoicePaymentMethodId, + shippingMethodId: this.baseConfig.defaultShippingMethod, + countryId: this.baseConfig.currentCountryId, + accessKey: `SWSC${uuid}`, + homeEnabled: true, + navigationCategory: { + id: rootCategoryId, + name: `${this.namePrefix}UCP-${id}${this.nameSuffix}`, + displayNestedProducts: true, + type: 'page', + productAssignmentType: 'product', + }, + domains: [{ + id: domainId, + url, + languageId: this.baseConfig.currentLanguageId, + snippetSetId: this.baseConfig.currentSnippetSetId, + currencyId: this.baseConfig.currentCurrencyId, + }], + customerGroup: { + id: customerGroupId, + name: `${this.namePrefix}UCP-${id}${this.nameSuffix}`, + }, + languages: [{ id: this.baseConfig.currentLanguageId }], + countries: [{ id: this.baseConfig.currentCountryId }], + shippingMethods: [{ id: this.baseConfig.defaultShippingMethod }], + paymentMethods: [{ id: this.baseConfig.invoicePaymentMethodId }], + currencies: [{ id: this.baseConfig.currentCurrencyId }], + ...overrides, + }], + }, + }, + }); + expect(response.ok(), await response.text()).toBeTruthy(); + + this.createdSalesChannelIds.push(uuid); + this.addCreatedRecord('category', rootCategoryId); + this.addCreatedRecord('customer_group', customerGroupId); + + const salesChannelResponse = await this.AdminApiClient.get(`./sales-channel/${uuid}`); + expect(salesChannelResponse.ok(), await salesChannelResponse.text()).toBeTruthy(); + const { data: salesChannel } = (await salesChannelResponse.json()) as { data: SalesChannel }; + + return { salesChannel, url }; + } + + async listUcpSalesChannels(): Promise { + const response = await this.AdminApiClient.get('./_admin/ucp/sales-channels'); + expect(response.ok(), await response.text()).toBeTruthy(); + + return ((await response.json()) as { data: UcpSalesChannelListEntry[] }).data; + } + + async getUcpConfig(salesChannelId: string): Promise { + const response = await this.AdminApiClient.get(`./_admin/ucp/sales-channels/${salesChannelId}/config`); + expect(response.ok(), await response.text()).toBeTruthy(); + + 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(); + + return ((await response.json()) as { data: { ucp: UcpProfileDocument['ucp'] } }).data.ucp; + } + + /** + * Raw config write. The route merges the payload over the stored row, so a partial payload is + * fine. Returns the response unasserted, for specs that expect a refusal. + */ + async saveUcpConfig(salesChannelId: string, payload: Partial) { + if (!this.createdSalesChannelIds.includes(salesChannelId) && !this.originalConfigBySalesChannelId.has(salesChannelId)) { + this.originalConfigBySalesChannelId.set(salesChannelId, await this.getUcpConfig(salesChannelId)); + } + + const configWrite = await this.AdminApiClient.fetch(`./_admin/ucp/sales-channels/${salesChannelId}/config`, { method: 'PUT', data: payload }); + if (configWrite.ok() && payload.active === true) { + this.activatedSalesChannelIds.add(salesChannelId); + } + + return configWrite; + } + + /** + * Turns UCP on with every capability, the REST transport and the agent host allowlisted, so + * the worker's agent profile passes the SDK's per-channel host checks. + */ + async activateUcp(salesChannelId: string, overrides: Partial = {}): Promise { + const response = await this.saveUcpConfig(salesChannelId, { + active: true, + enabledCapabilities: ALL_UCP_CAPABILITIES, + enabledTransports: ['rest'], + platformAllowlist: [this.agentHost], + remoteProfileAllowlist: [this.agentHost], + agentAllowlist: [this.agentHost], + ...overrides, + }); + expect(response.ok(), await response.text()).toBeTruthy(); + + return ((await response.json()) as { data: UcpConfigPayload }).data; + } + + /** + * Core revokes refresh tokens on every user insert, which MariaDB 11.6+ snapshot isolation + * rejects (error 1020) while other workers log in. Before 6.7.13.1 the user row is already + * committed by then, so a failed attempt is registered for cleanup when the user exists. + */ + async createUserRetryingTokenConflicts(overrides: Partial = {}): Promise { + const password = overrides.password ?? 'shopware'; + + for (let attempt = 1; ; attempt++) { + const { uuid: id } = this.IdProvider.getIdPair(); + try { + return await this.createUser({ ...overrides, id, password }); + } + catch (error) { + const userLookup = await this.AdminApiClient.get(`./user/${id}`); + if (userLookup.ok()) { + this.addCreatedRecord('user', id); + + return { ...((await userLookup.json()) as { data: User }).data, password }; + } + if (attempt === USER_CREATION_ATTEMPTS) { + throw error; + } + } + } + } + + /** The error code of a refused config write, or null when the response carries none. */ + static async refusalCode(response: { json(): Promise }): Promise { + const body = (await response.json()) as ApiErrorBody; + + return body.errors?.[0]?.code ?? null; + } + + /** + * Every step runs even when an earlier one fails: a channel left behind blocks the ATS + * registry from deleting its category and customer group. + */ + async cleanUpUcpEntities(): Promise { + if (!this.shouldCleanUp) { + return; + } + + const failures: string[] = []; + + for (const [salesChannelId, originalConfig] of this.originalConfigBySalesChannelId) { + const restore = await this.AdminApiClient.fetch(`./_admin/ucp/sales-channels/${salesChannelId}/config`, { + method: 'PUT', + data: originalConfig, + }); + if (!restore.ok()) { + failures.push(`restoring the UCP config of ${salesChannelId}: ${restore.status()} ${await restore.text()}`); + } + } + + const keyedSalesChannelIds = this.createdSalesChannelIds.filter(id => this.activatedSalesChannelIds.has(id)); + if (keyedSalesChannelIds.length > 0 && !this.console?.isAvailable()) { + console.warn( + `UCP console unavailable (${this.console?.describe() ?? 'none configured'}), signing keys left in ucp_signing_keys for sales channels: ${keyedSalesChannelIds.join(', ')}`, + ); + } + else if (this.console) { + for (const salesChannelId of keyedSalesChannelIds) { + try { + for (const signingKey of this.console.listSigningKeys(salesChannelId)) { + this.console.deleteSigningKey(salesChannelId, signingKey.kid); + } + } + catch (error) { + failures.push(`deleting the signing keys of ${salesChannelId}: ${error instanceof Error ? error.message : String(error)}`); + } + } + } + + if (this.createdSalesChannelIds.length > 0) { + const deletion = await this.AdminApiClient.post('./_action/sync', { + data: { + 'delete-sales-channel': { + entity: 'sales_channel', + action: 'delete', + payload: this.createdSalesChannelIds.map(id => ({ id })), + }, + }, + }); + if (!deletion.ok()) { + failures.push(`deleting the created sales channels: ${deletion.status()} ${await deletion.text()}`); + } + } + + if (failures.length > 0) { + throw new Error(`UCP test data cleanup failed:\n- ${failures.join('\n- ')}`); + } + } +} diff --git a/tests/acceptance/services/pluginSource.ts b/tests/acceptance/services/pluginSource.ts new file mode 100644 index 0000000..3c5f7c2 --- /dev/null +++ b/tests/acceptance/services/pluginSource.ts @@ -0,0 +1,188 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const PLUGIN_COMPOSER_NAME = 'shopware/agentic-commerce'; + +function findUpwards(start: string, marker: string): string | null { + let dir = path.resolve(start); + const { root } = path.parse(dir); + + while (dir !== root) { + if (fs.existsSync(path.join(dir, marker))) { + return dir; + } + dir = path.dirname(dir); + } + + return null; +} + +function isPluginDir(dir: string): boolean { + const composerJson = path.join(dir, 'composer.json'); + if (!fs.existsSync(composerJson)) { + return false; + } + + try { + const composer = JSON.parse(fs.readFileSync(composerJson, 'utf8')) as { name?: string }; + + return composer.name === PLUGIN_COMPOSER_NAME; + } + catch { + return false; + } +} + +/** + * The Shopware project the lane serves: `SHOPWARE_DIR`, else the nearest ancestor with `bin/console`. + */ +export function resolveShopwareDir(): string { + const fromEnv = process.env.SHOPWARE_DIR; + if (fromEnv) { + if (!fs.existsSync(path.join(fromEnv, 'bin', 'console'))) { + throw new Error(`SHOPWARE_DIR=${fromEnv} has no bin/console.`); + } + + return path.resolve(fromEnv); + } + + const found = findUpwards(import.meta.dirname, path.join('bin', 'console')); + if (found === null) { + throw new Error( + 'Cannot locate the Shopware project: no bin/console above this suite. Set SHOPWARE_DIR to the project the lane serves.', + ); + } + + return found; +} + +/** + * The plugin checkout whose sources the fixtures read: `PLUGIN_DIR`, else the suite's own parent, + * else whichever `custom/plugins` directory holds the plugin's Composer package. Its directory + * name differs between installs (`SwagAgenticCommerce` from a store archive and in CI). + */ +export function resolvePluginDir(): string { + const candidates = [ + process.env.PLUGIN_DIR, + path.resolve(import.meta.dirname, '..', '..', '..'), + ].filter((candidate): candidate is string => typeof candidate === 'string'); + + try { + const pluginsDir = path.join(resolveShopwareDir(), 'custom', 'plugins'); + candidates.push(...fs.readdirSync(pluginsDir).map(entry => path.join(pluginsDir, entry))); + } + catch { + // No Shopware dir: only the explicit candidates remain. + } + + const pluginDir = candidates.find(isPluginDir); + if (pluginDir === undefined) { + throw new Error(`Cannot locate the ${PLUGIN_COMPOSER_NAME} checkout. Tried: ${candidates.join(', ')}. Set PLUGIN_DIR.`); + } + + return pluginDir; +} + +export function readUcpProtocolVersion(): string { + const source = fs.readFileSync(path.join(resolvePluginDir(), 'src', 'Ucp', 'UcpProtocol.php'), 'utf8'); + const match = source.match(/const VERSION = '(\d{4}-\d{2}-\d{2})'/); + if (match === null) { + throw new Error('UcpProtocol::VERSION not found in src/Ucp/UcpProtocol.php.'); + } + + return match[1]; +} + +export interface PrivilegeRole { + privileges: string[] + dependencies: string[] +} + +export type PrivilegeMapping = Map>; + +interface PrivilegeMappingEntry { + key: string + roles: Record +} + +declare global { + var Shopware: unknown; +} + +let privilegeMapping: Promise | undefined; + +/** + * Evaluates the Administration ACL file with a stub `Shopware` global that records every + * `addPrivilegeMappingEntry` call, so the privilege sets come from the shipped source. + * + * Node evaluates an ES module once per process, so a second import would register nothing; + * the first result is kept for every later caller in the worker. + */ +export function readAdminPrivilegeMapping(): Promise { + privilegeMapping ??= evaluateAdminPrivilegeMapping().catch((error: unknown) => { + privilegeMapping = undefined; + throw error; + }); + + return privilegeMapping; +} + +async function evaluateAdminPrivilegeMapping(): Promise { + const aclFile = path.join( + resolvePluginDir(), + 'src', 'Resources', 'app', 'administration', 'src', 'extension', 'sw-sales-channel', 'acl', 'index.js', + ); + const mapping: PrivilegeMapping = new Map(); + + globalThis.Shopware = { + Service: () => ({ + addPrivilegeMappingEntry: (entry: PrivilegeMappingEntry) => { + mapping.set(entry.key, { ...mapping.get(entry.key), ...entry.roles }); + }, + }), + }; + + try { + await import(pathToFileURL(aclFile).href); + } + finally { + globalThis.Shopware = undefined; + } + + if (mapping.size === 0) { + throw new Error(`${aclFile} registered no privilege mapping.`); + } + + return mapping; +} + +export interface ResolvedRole { + /** Role keys, e.g. `ucp.editor` and `ucp.viewer`, as `acl.can()` in the Administration checks them. */ + keys: string[] + /** DAL privileges, dependencies included. */ + privileges: string[] +} + +export function resolveRole(mapping: PrivilegeMapping, roleKey: string, seen = new Set()): ResolvedRole { + if (seen.has(roleKey)) { + return { keys: [], privileges: [] }; + } + seen.add(roleKey); + + const [key, role] = roleKey.split('.'); + const definition = mapping.get(key)?.[role]; + if (definition === undefined) { + throw new Error(`Role ${roleKey} is not in the privilege mapping (known: ${[...mapping.keys()].join(', ')}).`); + } + + const keys = [roleKey]; + const privileges = [...definition.privileges]; + for (const dependency of definition.dependencies) { + const resolved = resolveRole(mapping, dependency, seen); + keys.push(...resolved.keys); + privileges.push(...resolved.privileges); + } + + return { keys: [...new Set(keys)], privileges: [...new Set(privileges)] }; +} diff --git a/tests/acceptance/tests/KnownBlocked/path-prefixed-domain.spec.ts b/tests/acceptance/tests/KnownBlocked/path-prefixed-domain.spec.ts new file mode 100644 index 0000000..4e9667d --- /dev/null +++ b/tests/acceptance/tests/KnownBlocked/path-prefixed-domain.spec.ts @@ -0,0 +1,37 @@ +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('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/Setup/test-data.spec.ts b/tests/acceptance/tests/Setup/test-data.spec.ts new file mode 100644 index 0000000..a95a047 --- /dev/null +++ b/tests/acceptance/tests/Setup/test-data.spec.ts @@ -0,0 +1,139 @@ +import { expect, test } from '@fixtures/AcceptanceTest'; +import { FEED_SALES_CHANNEL_TYPE_ID, UCP_SALES_CHANNEL_TYPE_NOT_SUPPORTED, UcpTestDataService } from '@services/UcpTestDataService'; +import type { UcpProfileDocument } from '@services/UcpTestDataService'; +import { readAdminPrivilegeMapping, resolveRole } from '@services/pluginSource'; +import { UCP_ROLES } from '@fixtures/UcpAclUsers'; + +test.describe('Isolated UCP test data @Setup', () => { + test('the worker channel sits on its own path-prefixed domain', ({ DefaultSalesChannel, SalesChannelBaseConfig }) => { + expect(DefaultSalesChannel.url).toMatch(/\/test-[0-9a-f]{32}\/$/); + expect(DefaultSalesChannel.salesChannel.typeId).toBe(SalesChannelBaseConfig.storefrontTypeId); + }); + + test('activating one channel leaves the others untouched', async ({ TestDataService, DefaultSalesChannel }) => { + const activatedChannel = await TestDataService.createStorefrontSalesChannel(); + expect(activatedChannel.url).not.toEqual(DefaultSalesChannel.url); + + const activatedConfig = await TestDataService.activateUcp(activatedChannel.salesChannel.id); + expect(activatedConfig.active).toBe(true); + + const untouchedConfig = await TestDataService.getUcpConfig(DefaultSalesChannel.salesChannel.id); + expect(untouchedConfig.active).toBe(false); + + const channelsOfferedUcp = await TestDataService.listUcpSalesChannels(); + expect(channelsOfferedUcp.find(entry => entry.id === activatedChannel.salesChannel.id)?.ucp).toMatchObject({ active: true }); + }); + + test('a headless channel is offered UCP', async ({ TestDataService }) => { + const headlessChannel = await TestDataService.createHeadlessSalesChannel(); + + const channelsOfferedUcp = await TestDataService.listUcpSalesChannels(); + expect(channelsOfferedUcp.map(entry => entry.id)).toContain(headlessChannel.salesChannel.id); + + const headlessConfig = await TestDataService.activateUcp(headlessChannel.salesChannel.id); + expect(headlessConfig.active).toBe(true); + }); + + test('a product-feed channel is not offered UCP and refuses activation', async ({ TestDataService }) => { + const feedChannel = await TestDataService.createFeedSalesChannel(); + expect(feedChannel.salesChannel.typeId).toBe(FEED_SALES_CHANNEL_TYPE_ID); + + const channelsOfferedUcp = await TestDataService.listUcpSalesChannels(); + expect(channelsOfferedUcp.map(entry => entry.id)).not.toContain(feedChannel.salesChannel.id); + + const refusal = await TestDataService.saveUcpConfig(feedChannel.salesChannel.id, { active: true }); + expect(refusal.status()).toBe(400); + expect(await UcpTestDataService.refusalCode(refusal)).toBe(UCP_SALES_CHANNEL_TYPE_NOT_SUPPORTED); + }); +}); + +test.describe('Test agent profile host @Setup', () => { + test('publishes a profile the shop can fetch, with the agent\'s public key', async ({ UcpAgentProfileHost, page }) => { + const agent = await UcpAgentProfileHost.publish({ label: 'setup-agent' }); + + expect(agent.profileUrl).toContain(`/ucp-acceptance-agents/${agent.kid}.json`); + expect(agent.agentHeader).toBe(`setup-agent; profile="${agent.profileUrl}"`); + expect(agent.privateKeyPem).toContain('BEGIN PRIVATE KEY'); + + const servedProfile = await page.request.get(new URL(`ucp-acceptance-agents/${agent.kid}.json`, process.env.APP_URL).toString()); + expect(servedProfile.ok(), await servedProfile.text()).toBeTruthy(); + + const publishedProfile = (await servedProfile.json()) as UcpProfileDocument; + expect(publishedProfile.signing_keys).toEqual([expect.objectContaining({ kid: agent.kid, alg: 'ES256', crv: 'P-256' })]); + expect(publishedProfile.ucp.version).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + + test('the published profile negotiates every capability an activated channel serves', async ({ UcpAgentProfileHost, TestDataService, page }) => { + const negotiatingChannel = await TestDataService.createStorefrontSalesChannel(); + await TestDataService.activateUcp(negotiatingChannel.salesChannel.id); + const servedByChannel = await TestDataService.getProfilePreview(negotiatingChannel.salesChannel.id); + + const agent = await UcpAgentProfileHost.publish(); + const servedProfile = await page.request.get(new URL(`ucp-acceptance-agents/${agent.kid}.json`, process.env.APP_URL).toString()); + const publishedProfile = ((await servedProfile.json()) as UcpProfileDocument).ucp; + + expect(publishedProfile.version, 'the SDK refuses a profile at another protocol version').toBe(servedByChannel.version); + expect(Object.keys(servedByChannel.capabilities).length).toBeGreaterThan(0); + + const negotiated = Object.keys(servedByChannel.capabilities).filter(name => (publishedProfile.capabilities[name] ?? []) + .some(agentEntry => servedByChannel.capabilities[name].some(channelEntry => channelEntry.version === agentEntry.version))); + expect(negotiated.sort(), 'every capability the channel serves survives the SDK\'s intersection') + .toEqual(Object.keys(servedByChannel.capabilities).sort()); + + for (const name of negotiated) { + const bases = publishedProfile.capabilities[name][0].extends ?? []; + expect(bases.length === 0 || bases.some(base => negotiated.includes(base)), `${name} extends a capability that did not survive`).toBe(true); + } + }); + + test('a capabilities override replaces the default set', async ({ UcpAgentProfileHost, page }) => { + const agentWithoutCapabilities = await UcpAgentProfileHost.publish({ capabilities: {} }); + + const servedProfile = await page.request.get(new URL(`ucp-acceptance-agents/${agentWithoutCapabilities.kid}.json`, process.env.APP_URL).toString()); + + expect(((await servedProfile.json()) as UcpProfileDocument).ucp.capabilities).toEqual({}); + }); +}); + +test.describe('UCP privilege mapping @Setup', () => { + test('a second read in the same worker still yields every UCP role', async () => { + const firstRead = await readAdminPrivilegeMapping(); + const repeatedRead = await readAdminPrivilegeMapping(); + + expect(repeatedRead.size).toBeGreaterThan(0); + expect(repeatedRead).toBe(firstRead); + for (const role of UCP_ROLES) { + expect(resolveRole(repeatedRead, role).keys).toContain(role); + } + }); +}); + +test.describe('UCP ACL users @Setup', () => { + test('a ucp.viewer reaches the Administration with the role attached', async ({ UcpAclUsers, TestDataService }) => { + const viewer = await UcpAclUsers.as('ucp.viewer'); + + expect(viewer.privileges).toContain('ucp.viewer'); + expect(viewer.privileges).not.toContain('ucp.editor'); + expect(viewer.privileges).toContain('sales_channel:read'); + + const storedUser = await TestDataService.getUserById(viewer.user.id); + expect(storedUser.admin).toBe(false); + await expect(viewer.page.locator('.sw-admin-menu')).toBeVisible(); + }); +}); + +test.describe('Console wrapper @Setup @UcpConsole', () => { + test('manages signing keys for a sales channel through bin/console', async ({ UcpConsole, TestDataService }) => { + expect(UcpConsole.isAvailable(), `bin/console is not reachable via ${UcpConsole.describe()}; set UCP_CONSOLE`).toBe(true); + + const signingKeyChannel = await TestDataService.createStorefrontSalesChannel(); + const kid = `acceptance-${Date.now()}`; + + UcpConsole.generateSigningKey(signingKeyChannel.salesChannel.id, kid); + expect(UcpConsole.listSigningKeys(signingKeyChannel.salesChannel.id).map(key => key.kid)).toContain(kid); + + UcpConsole.retireSigningKey(signingKeyChannel.salesChannel.id, kid); + UcpConsole.deleteSigningKey(signingKeyChannel.salesChannel.id, kid); + expect(UcpConsole.listSigningKeys(signingKeyChannel.salesChannel.id).map(key => key.kid)).not.toContain(kid); + }); +}); diff --git a/tests/acceptance/tsconfig.json b/tests/acceptance/tsconfig.json index 1c91efb..495a451 100644 --- a/tests/acceptance/tsconfig.json +++ b/tests/acceptance/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { "lib": ["ES2022"], + "target": "ES2022", "module": "Preserve", "skipLibCheck": true, "allowJs": false,