diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 12c8006..1e4847c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -449,11 +449,11 @@ jobs: - run: agentic-commerce/bin/ci-smoke.sh "$GITHUB_WORKSPACE/shopware" env: SHOPWARE_REF: ${{ matrix.lane }} - # Run the functional suite on every shopware-matrix lane. ci-smoke installs - # Shopware's dev deps so the suite runs on the lane's own phpunit + # Run the integration and functional suites on every shopware-matrix lane. ci-smoke + # installs Shopware's dev deps so the suites run on the lane's own phpunit # (6.5->9.x, 6.6->10.x, trunk->11.x; Shopware's test base classes are coupled # to the phpunit major), covering lane-specific behavior everywhere the smoke runs. - CI_SMOKE_RUN_FUNCTIONAL: '1' + CI_SMOKE_RUN_PHPUNIT: '1' CI_COMPOSER_AUDIT_OUTPUT: ${{ github.workspace }}/composer-audit.json - name: Upload Composer advisory report if: always() diff --git a/AGENTS.md b/AGENTS.md index ec95b5f..3f982d9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,7 +55,8 @@ deployed HTTP stack: regardless of how many collaborators it stubs. 2. **`integration`** (`tests/Integration`, `composer test:integration`) — DB-backed tests that use a real Doctrine connection through the kernel (e.g. the migration tests). Reserve this tier for - tests that genuinely touch the database/kernel; mock-only tests belong in `unit`. + tests that genuinely touch the database/kernel; mock-only tests belong in `unit`. It gates in CI + on **every** `shopware-matrix` lane (`CI_SMOKE_RUN_PHPUNIT=1`). 3. **`functional`** (`tests/Functional`, `composer test:functional`) — boots a real Shopware test kernel and drives UCP runtime routes end-to-end through a real Symfony `KernelBrowser` (the full HttpKernel request/response cycle, kernel events included), against `APP_URL` — the test @@ -68,7 +69,7 @@ deployed HTTP stack: suite assumes a booted kernel (no per-test skip-guards) — run it via `composer test:functional` against a configured lane (e.g. the `shopware-6-6-branch-web` container), never under the fast-path bootstrap. It gates in CI on **every** `shopware-matrix` lane - (`CI_SMOKE_RUN_FUNCTIONAL=1`). + (`CI_SMOKE_RUN_PHPUNIT=1`). The flow tests share `UcpFlowTestBehaviour`, which reproduces the SDK request-context handshake offline: it sets the sales-channel config the smoke sets (`active`, `signaturePolicy=log`, @@ -138,13 +139,14 @@ The `bin/` smoke scripts share helpers from `bin/lib/`: - `bin/lib/lane.sh` — container helpers (`web`, `db_query`, …) operating on the sourcing script's `compose` array and `container_runtime`. - `bin/lib/smoke/*.sh` — `bin/ci-smoke.sh` is a thin orchestrator that, after bootstrap, sources and - runs named stage modules (`discovery`, `identity`, `checkout`). Each prints a `>>> smoke: ` - banner, so a failure names the area. Stages share the orchestrator's shell scope (they are - sourced, not subprocesses); add a new check by adding a `smoke_` module and calling it from - the orchestrator. Before adding a smoke check, confirm it cannot be a `functional` test (see _Test - layering_ above) — smoke is for deployed-stack concerns only. With `CI_SMOKE_RUN_FUNCTIONAL=1` - (set on every `shopware-matrix` lane) the orchestrator installs Shopware's dev deps and runs the - functional suite on the lane's own phpunit after the HTTP smoke. + runs named stage modules (`discovery`, `identity`, `checkout`, `signature`). Each prints a + `>>> smoke: ` banner, so a failure names the area. Stages share the orchestrator's shell + scope (they are sourced, not subprocesses); add a new check by adding a `smoke_` module and + calling it from the orchestrator. Before adding a smoke check, confirm it cannot be a `functional` + test (see _Test layering_ above) — smoke is for deployed-stack concerns only. With + `CI_SMOKE_RUN_PHPUNIT=1` (set on every `shopware-matrix` lane) the orchestrator installs + Shopware's dev deps and runs the integration and functional suites on the lane's own phpunit after + the HTTP smoke. Lint every shell script with `shellcheck -x bin/*.sh bin/lib/*.sh` (the CI `shell-lint` job; `.shellcheckrc` disables `SC2016` for jq filters). `-x` follows the `# shellcheck source=` diff --git a/bin/ci-smoke.sh b/bin/ci-smoke.sh index 5fd011f..3225661 100755 --- a/bin/ci-smoke.sh +++ b/bin/ci-smoke.sh @@ -539,28 +539,33 @@ source "${PLUGIN_ROOT}/bin/lib/smoke/discovery.sh" source "${PLUGIN_ROOT}/bin/lib/smoke/identity.sh" # shellcheck source=bin/lib/smoke/checkout.sh source "${PLUGIN_ROOT}/bin/lib/smoke/checkout.sh" +# shellcheck source=bin/lib/smoke/signature.sh +source "${PLUGIN_ROOT}/bin/lib/smoke/signature.sh" smoke_discovery smoke_identity smoke_checkout +smoke_signature -# Optionally run the functional suite inside the already-booted stack. These tests -# boot a real Shopware test kernel (SHOPWARE_PROJECT_DIR unset + APP_ENV=test) and drive UCP +# Optionally run the integration and functional suites inside the already-booted stack. These +# tests boot a real Shopware test kernel (SHOPWARE_PROJECT_DIR unset + APP_ENV=test) and drive UCP # routes through a real Symfony browser, rather than hitting the deployed HTTP stack. They use # Shopware core's test base classes, which are coupled to the lane's phpunit major # (6.5->9, 6.6->10, trunk->11), so they must run on the lane's OWN phpunit, not the plugin's # pinned .tools 10.5. The smoke stack installs Shopware --no-dev, so pull in the dev deps here: # bin/run.php then prefers the platform phpunit and tests/bootstrap.php registers the plugin's # src + Tests namespaces on the platform autoloader. -if [[ "${CI_SMOKE_RUN_FUNCTIONAL:-0}" == "1" ]]; then - echo "Installing Shopware dev dependencies so the functional suite runs on the lane's own phpunit." +if [[ "${CI_SMOKE_RUN_PHPUNIT:-0}" == "1" ]]; then + echo "Installing Shopware dev dependencies so the PHPUnit suites run on the lane's own phpunit." web composer install -d /var/www/html --no-interaction --no-progress --no-scripts - echo "Running functional suite (lane phpunit, booting test kernel)." - "${compose[@]}" exec -T \ - -e SHOPWARE_PROJECT_DIR= \ - -e APP_ENV=test \ - -w /var/www/html/custom/plugins/SwagAgenticCommerce \ - web php bin/run.php phpunit --testsuite functional + for suite in integration functional; do + echo "Running ${suite} suite (lane phpunit, booting test kernel)." + "${compose[@]}" exec -T \ + -e SHOPWARE_PROJECT_DIR= \ + -e APP_ENV=test \ + -w /var/www/html/custom/plugins/SwagAgenticCommerce \ + web php bin/run.php phpunit --testsuite "${suite}" + done fi echo "Smoke test passed for ${SHOPWARE_DIR}." diff --git a/bin/lib/smoke/identity.sh b/bin/lib/smoke/identity.sh index 4cd0f36..65a029b 100644 --- a/bin/lib/smoke/identity.sh +++ b/bin/lib/smoke/identity.sh @@ -20,10 +20,4 @@ smoke_identity() { fi rm -f "${tokenize_body_file}" - - # NOTE: strict-signature acceptance/rejection is intentionally NOT asserted here. This smoke - # sends unsigned requests, and flipping signaturePolicy=strict at runtime did not reject the - # no-signature request (the SDK rejects bad signatures, not absent ones, on this path). Signed - # request verification is covered by the conformance suite (bin/validate-ucp-store.sh - # conformance) and the manual-testing doc. } diff --git a/bin/lib/smoke/signature.sh b/bin/lib/smoke/signature.sh new file mode 100644 index 0000000..56d955f --- /dev/null +++ b/bin/lib/smoke/signature.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +# bin/lib/smoke/signature.sh — strict signature policy over the deployed HTTP stack. +# ucp:config:set writes the stored config row, which is what the runtime reads; system config +# alone cannot switch the policy. The body check pins the RFC 9421 path, since a disallowed +# profile host also answers 401. Sourced by ci-smoke.sh. + +smoke_signature() { + echo ">>> smoke: signature" + + web php /var/www/html/bin/console ucp:config:set --sales-channel="${sales_channel_id}" --signature-policy=strict + + local strict_body_file strict_status + strict_body_file="$(mktemp)" + + strict_status="$(curl -sS -o "${strict_body_file}" -w '%{http_code}' -X POST "${BASE_URL}/ucp/v1/catalog/search" -H "${ucp_agent_header}" -H 'content-type: application/json' -d '{"query":"smoke","limit":1}')" + if [[ "${strict_status}" != "401" ]] || ! grep -q 'Missing signature headers.' "${strict_body_file}"; then + echo "Expected an unsigned catalog search to be rejected with 401 'Missing signature headers.' under strict, got ${strict_status}." >&2 + cat "${strict_body_file}" >&2 + rm -f "${strict_body_file}" + exit 1 + fi + + rm -f "${strict_body_file}" + + # The unsigned e2e step after this script reuses the stack. + web php /var/www/html/bin/console ucp:config:set --sales-channel="${sales_channel_id}" --signature-policy=log +} \ No newline at end of file diff --git a/docs/qa.md b/docs/qa.md index f51fc9c..7cb0d71 100644 --- a/docs/qa.md +++ b/docs/qa.md @@ -26,7 +26,7 @@ including completing a checkout into a **real Shopware order** and reading it ba token (via the shared `UcpFlowTestBehaviour`). It requires the booting bootstrap (`SHOPWARE_PROJECT_DIR` unset + `APP_ENV=test`) and, like core, assumes a booted kernel — run it against a configured lane with `composer test:functional`. In CI it gates on **every** -`shopware-matrix` lane (`CI_SMOKE_RUN_FUNCTIONAL=1`). +`shopware-matrix` lane (`CI_SMOKE_RUN_PHPUNIT=1`). It runs on the **lane's own** phpunit: Shopware core's test base classes are coupled to each lane's phpunit major (6.5→9.x, 6.6→10.x, trunk→11.x), so a single pinned phpunit can't span lanes. diff --git a/tests/Functional/Ucp/UcpRequestContextGuardTest.php b/tests/Functional/Ucp/UcpRequestContextGuardTest.php index 3413489..bda60a0 100644 --- a/tests/Functional/Ucp/UcpRequestContextGuardTest.php +++ b/tests/Functional/Ucp/UcpRequestContextGuardTest.php @@ -7,8 +7,8 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use Shopware\Core\DevOps\Environment\EnvironmentHelper; -use Shopware\Core\Framework\Test\TestCaseBase\IntegrationTestBehaviour; use Shopware\Core\Framework\Test\TestCaseBase\KernelLifecycleManager; +use Swag\AgenticCommerce\Ucp\Config\UcpConfigService; use Symfony\Component\HttpFoundation\Response; /** @@ -18,6 +18,12 @@ * a 422, replacing the equivalent shell-smoke assertion with a readable PHP test, and that the * OAuth-metadata endpoint stays a 501 stub until identity linking is enabled. * + * The strict-policy tests persist `signaturePolicy=strict` through UcpConfigService (the service behind + * `ucp:config:set`), overriding the `log` system config from {@see UcpFlowTestBehaviour::configureUcpRuntime()}. + * A2A stays incomplete while the SDK's RequestContextListener::isUcpRequest() exempts that path. The + * embedded page must still be served: browsers cannot sign an iframe load, and the Embedded Checkout + * Protocol authorizes it through the token in the URL. + * * Requests target `APP_URL` — the test database's default storefront sales-channel domain — exactly * as Shopware's own functional tests do. * @@ -25,7 +31,7 @@ */ final class UcpRequestContextGuardTest extends TestCase { - use IntegrationTestBehaviour; + use UcpFlowTestBehaviour; #[Test] public function testRuntimeRequestWithoutUcpAgentHeaderIsRejected(): void @@ -54,6 +60,76 @@ public function testOAuthMetadataStaysUnsupportedUntilIdentityLinkingIsEnabled() self::assertSame(Response::HTTP_NOT_IMPLEMENTED, $browser->getResponse()->getStatusCode()); } + #[Test] + public function testUnsignedCatalogSearchIsRejectedUnderStrictSignaturePolicy(): void + { + $this->configureUcpRuntime(); + $this->enforceStrictSignaturePolicy(); + + $response = $this->ucpRequest('POST', '/ucp/v1/catalog/search', ['query' => 'Kernel', 'limit' => 1]); + + $this->assertMissingSignatureRejection($response); + } + + #[Test] + public function testUnsignedA2aRequestIsRejectedUnderStrictSignaturePolicy(): void + { + $this->configureUcpRuntime(); + $this->enforceStrictSignaturePolicy(); + + $response = $this->ucpRequest('POST', '/ucp/a2a', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'catalog.search', + 'params' => ['query' => 'Kernel', 'limit' => 1], + ]); + + // JSON-RPC errors are also HTTP 200, so only a served result counts as the known gap. + if (Response::HTTP_OK === $response->getStatusCode() && isset($this->decode($response)['result'])) { + self::markTestIncomplete('A2A skips the signature check under strict, see agentic-commerce-alliance/ucp-php-sdk#206.'); + } + + $this->assertMissingSignatureRejection($response); + } + + #[Test] + public function testUnsignedEmbeddedCartPageIsServedUnderStrictSignaturePolicy(): void + { + $this->configureUcpRuntime(); + $productId = $this->seedStorefrontProduct('Kernel Test Album'); + $create = $this->ucpRequest('POST', '/ucp/v1/carts', [ + 'line_items' => [['item' => ['id' => $productId, 'title' => 'Kernel Test Album', 'price' => 19.99], 'quantity' => 1]], + ]); + self::assertSame(Response::HTTP_CREATED, $create->getStatusCode()); + $cartId = $this->decode($create)['id']; + $this->enforceStrictSignaturePolicy(); + + $response = $this->ucpRequest('GET', '/ucp/embedded/cart/'.$cartId); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + self::assertStringStartsWith('text/html', (string) $response->headers->get('Content-Type')); + } + + private function enforceStrictSignaturePolicy(): void + { + $config = static::getContainer()->get(UcpConfigService::class)->saveConfig([ + 'signaturePolicy' => 'strict', + 'enabledTransports' => ['rest', 'a2a', 'embedded'], + // EmbeddedResponseListener answers 403 before routing while no origin is allowlisted. + 'embeddedAllowedOrigins' => [$this->ucpDomain], + ], $this->ucpSalesChannelId); + + self::assertSame('strict', $config->signaturePolicy); + } + + private function assertMissingSignatureRejection(Response $response): void + { + self::assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode()); + $message = $this->decode($response)['messages'][0] ?? []; + self::assertSame('signature_invalid', $message['code'] ?? null); + self::assertSame('Missing signature headers.', $message['content'] ?? null); + } + private function appUrl(): string { return rtrim((string) EnvironmentHelper::getVariable('APP_URL'), '/');