Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
20 changes: 11 additions & 9 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`,
Expand Down Expand Up @@ -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: <stage>`
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_<stage>` 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: <stage>` 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_<stage>` 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=`
Expand Down
25 changes: 15 additions & 10 deletions bin/ci-smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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}."
6 changes: 0 additions & 6 deletions bin/lib/smoke/identity.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
}
28 changes: 28 additions & 0 deletions bin/lib/smoke/signature.sh
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 1 addition & 1 deletion docs/qa.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
80 changes: 78 additions & 2 deletions tests/Functional/Ucp/UcpRequestContextGuardTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -18,14 +18,20 @@
* 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.
*
* @internal
*/
final class UcpRequestContextGuardTest extends TestCase
{
use IntegrationTestBehaviour;
use UcpFlowTestBehaviour;

#[Test]
public function testRuntimeRequestWithoutUcpAgentHeaderIsRejected(): void
Expand Down Expand Up @@ -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'), '/');
Expand Down
Loading