diff --git a/.github/workflows/agentic-commerce-e2e.yml b/.github/workflows/agentic-commerce-e2e.yml new file mode 100644 index 00000000000..dfc28c3e35a --- /dev/null +++ b/.github/workflows/agentic-commerce-e2e.yml @@ -0,0 +1,441 @@ +name: Agentic Commerce E2E + +## 本体 (ACP/UCP) ↔ eccube-api4 (OAuth2) ↔ sample-payment-plugin (決済ハンドラ) の +## 結合を検証する E2E。Job A スモーク + Job B checkout (OAuth2 認証 + create/update/complete)。 +## +## 通常 CI (main.yml) から workflow_call で呼び出され、push/PR で自動実行される。 +## workflow_dispatch では fork ブランチや run_payment を手動指定できる。 +## 自動実行時は fork の feature/agentic-commerce ブランチ・run_payment=true を既定にする。 +## (プラグインの EC-CUBE org 移行後、参照 URL/ブランチは別 PR で org 参照へ差し替える。) + +on: + workflow_dispatch: + inputs: + api4_ref: + description: 'eccube-api4 (nanasess fork) のブランチ' + type: string + default: 'feature/agentic-commerce' + sample_ref: + description: 'sample-payment-plugin (nanasess fork) のブランチ' + type: string + default: 'feature/agentic-commerce' + run_payment: + description: 'complete (決済実行) まで通す' + type: boolean + default: true + workflow_call: + inputs: + api4_ref: + type: string + required: false + default: 'feature/agentic-commerce' + sample_ref: + type: string + required: false + default: 'feature/agentic-commerce' + run_payment: + type: boolean + required: false + default: true + +permissions: + contents: read + +env: + ## VCS リポジトリ (Packagist 未公開の開発期間は fork を VCS 経由で引く) + API44_VCS: 'https://github.com/nanasess/eccube-api4.git' + SAMPLEPAYMENT44_VCS: 'https://github.com/nanasess/sample-payment-plugin.git' + +jobs: + ## ─────────────────────────────────────────────────────────────────────── + ## Job A: 本体 + api44 + samplepayment44 の三者共存 + discovery 生存確認。 + ## composer/autoload/DI 衝突を早期検知する実質スモーク。day 1 から green を狙う。 + ## ─────────────────────────────────────────────────────────────────────── + integration-smoke: + name: Integration smoke (本体 + api44 + samplepayment44) + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + php: [ '8.2', '8.3', '8.4', '8.5' ] + db: [ pgsql, mysql ] + include: + - db: pgsql + database_url: postgres://postgres:password@127.0.0.1:5432/eccube_db + database_server_version: 18 + database_charset: utf8 + - db: mysql + database_url: mysql://root:password@127.0.0.1:3306/eccube_db + database_server_version: 8 + database_charset: utf8mb4 + + services: + mailcatcher: + image: schickling/mailcatcher + ports: + - 1080:1080 + - 1025:1025 + + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + persist-credentials: false + + ## Docker Hub からの pull はタイムアウトで CI が不安定になるため、 + ## DB はコンテナではなく setup アクション (apt) でインストールする (plugin-test.yml 準拠) + - name: Setup MySQL + if: matrix.db == 'mysql' + uses: ankane/setup-mysql@7fef9e1e6d7041dccbe4cda8e2c5940e49db8f30 # v1 + with: + mysql-version: '8.4' + - name: Configure MySQL + if: matrix.db == 'mysql' + run: | + for _ in $(seq 1 30); do mysqladmin ping -h 127.0.0.1 --silent && break; sleep 1; done + mysqladmin ping -h 127.0.0.1 --silent || { echo 'MySQL did not become ready within 30s'; exit 1; } + ## DATABASE_URL は TCP(127.0.0.1) 接続のため、TCP 経路でアクセスする root@'%' にも認証情報を設定する + sudo mysql -e "ALTER USER 'root'@'localhost' IDENTIFIED WITH caching_sha2_password BY 'password'; CREATE USER IF NOT EXISTS 'root'@'%' IDENTIFIED WITH caching_sha2_password BY 'password'; GRANT ALL PRIVILEGES ON *.* TO 'root'@'%' WITH GRANT OPTION; FLUSH PRIVILEGES;" + mysql -h 127.0.0.1 -P 3306 -u root -ppassword -e 'SELECT VERSION()' + + - name: Setup PostgreSQL + if: matrix.db == 'pgsql' + uses: ankane/setup-postgres@6d3ffa1aa7498a42b79e9f9f5838b99971839300 # v1 + with: + postgres-version: '18' + user: postgres + - name: Configure PostgreSQL + if: matrix.db == 'pgsql' + run: | + for _ in $(seq 1 30); do pg_isready -h 127.0.0.1 && break; sleep 1; done + pg_isready -h 127.0.0.1 || { echo 'PostgreSQL did not become ready within 30s'; exit 1; } + psql -h 127.0.0.1 -U postgres -c "ALTER USER postgres PASSWORD 'password'" + PGPASSWORD=password psql -h 127.0.0.1 -U postgres -c 'SELECT VERSION()' + + - name: Setup PHP + uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2 + with: + php-version: ${{ matrix.php }} + # 追加拡張は指定しない (必要な拡張は setup-php 既定で揃う)。共有レンタルサーバー相当の環境を + # 再現するため sodium は意図的に外す: composer.json の config.platform.ext-sodium だけで + # api44 (lcobucci/jwt の ext-sodium 要求) が導入でき、実行時も RSA/openssl 経路で動作することを + # 検証する (#6827)。redis も本 E2E は FilesystemAdapter のため未使用。 + + - name: Initialize Composer + uses: ./.github/actions/composer + + - name: Generate ECCUBE_AUTH_MAGIC + run: echo "ECCUBE_AUTH_MAGIC=$(openssl rand -hex 32)" >> $GITHUB_ENV + + - name: Setup to EC-CUBE + env: + APP_ENV: 'prod' + DATABASE_URL: ${{ matrix.database_url }} + DATABASE_SERVER_VERSION: ${{ matrix.database_server_version }} + DATABASE_CHARSET: ${{ matrix.database_charset }} + ECCUBE_AUTH_MAGIC: ${{ env.ECCUBE_AUTH_MAGIC }} + run: | + echo "APP_ENV=${APP_ENV}" > .env + echo "ECCUBE_AUTH_MAGIC=${ECCUBE_AUTH_MAGIC}" >> .env + bin/console doctrine:database:create --env=dev + bin/console doctrine:schema:create --env=dev + bin/console eccube:fixtures:load --env=dev + + ## ── 結合の核心: 本体に api44 と samplepayment44 を共存インストール ── + ## fork のデフォルトブランチが旧名 (ec-cube/api・ec-cube/SamplePayment) のため、composer の + ## VCS ドライバはデフォルトブランチから決まる名前で repo をキーする → 新名 (api44/samplepayment44) + ## の feature ブランチが名前不一致でスキップされ "not found" になる。これを避けるため、 + ## 対象ブランチを clone して **path リポジトリ**として参照する (clone 先 composer.json の名前で解決)。 + - name: Clone plugins & register path repositories + env: + API4_REF: ${{ inputs.api4_ref || 'feature/agentic-commerce' }} + SAMPLE_REF: ${{ inputs.sample_ref || 'feature/agentic-commerce' }} + run: | + git clone --depth 1 --branch "${API4_REF}" "${API44_VCS}" "${RUNNER_TEMP}/api44" + git clone --depth 1 --branch "${SAMPLE_REF}" "${SAMPLEPAYMENT44_VCS}" "${RUNNER_TEMP}/samplepayment44" + ## ec-cube/plugin-installer は composer.json extra.id を必須参照する (通常は store API が供給)。 + ## api44 は extra.id を持たないため、path インストール用にダミー id を注入する (fork は改変しない)。 + jq '.extra.id = 990044' "${RUNNER_TEMP}/api44/composer.json" > "${RUNNER_TEMP}/api44/composer.json.tmp" + mv "${RUNNER_TEMP}/api44/composer.json.tmp" "${RUNNER_TEMP}/api44/composer.json" + composer config repositories.api44 path "${RUNNER_TEMP}/api44" + composer config repositories.samplepayment44 path "${RUNNER_TEMP}/samplepayment44" + + - name: Install & enable plugins (api44 + samplepayment44) + env: + APP_ENV: 'prod' + DATABASE_URL: ${{ matrix.database_url }} + DATABASE_SERVER_VERSION: ${{ matrix.database_server_version }} + DATABASE_CHARSET: ${{ matrix.database_charset }} + ECCUBE_AUTH_MAGIC: ${{ env.ECCUBE_AUTH_MAGIC }} + ## league/oauth2 等の dist 取得でレート制限回避にトークンを渡す + COMPOSER_AUTH: '{"github-oauth": {"github.com": "${{ secrets.GITHUB_TOKEN }}"}}' + run: | + ## path リポジトリの公開バージョン (composer.json の version) で取り込む + bin/console eccube:composer:require "ec-cube/api44:*" + bin/console eccube:composer:require "ec-cube/samplepayment44:*" + bin/console eccube:plugin:enable --code=Api44 + bin/console eccube:plugin:enable --code=SamplePayment44 + + ## 三者共存の実証: 全プラグイン enable 状態で DI コンテナが衝突なくコンパイルできる + - name: Assert three-way coexistence (DI / autoload) + env: + APP_ENV: 'prod' + DATABASE_URL: ${{ matrix.database_url }} + DATABASE_SERVER_VERSION: ${{ matrix.database_server_version }} + DATABASE_CHARSET: ${{ matrix.database_charset }} + ECCUBE_AUTH_MAGIC: ${{ env.ECCUBE_AUTH_MAGIC }} + run: | + ## cache:warmup が全プラグイン enable 状態で成功すること = DI/autoload に三者衝突が無いこと + bin/console cache:clear --no-warmup + bin/console cache:warmup + ## 両プラグインが dtb_plugin に enabled=1 で登録されていることを確認 + ## (EC-CUBE に eccube:plugin:list は無いため dtb_plugin を直接参照する) + bin/console dbal:run-sql "SELECT code, enabled FROM dtb_plugin WHERE code IN ('Api44','SamplePayment44')" | tee /tmp/plugins.txt + grep -q 'Api44' /tmp/plugins.txt || { echo 'Api44 not registered'; exit 1; } + grep -q 'SamplePayment44' /tmp/plugins.txt || { echo 'SamplePayment44 not registered'; exit 1; } + + ## discovery を公開状態にする (acp.json は acp_checkout_enabled ゲート / ucp は常時公開) + - name: Enable ACP checkout flag (discovery 公開のため) + env: + DATABASE_URL: ${{ matrix.database_url }} + DATABASE_SERVER_VERSION: ${{ matrix.database_server_version }} + DATABASE_CHARSET: ${{ matrix.database_charset }} + run: | + bin/console dbal:run-sql "UPDATE dtb_base_info SET acp_checkout_enabled = true" + ## BaseInfo は result cache (lifetime 3600s) されるため、SQL 直接更新後は + ## pool を消さないと acp.json ゲートが stale(false) のまま 404 になる。 + bin/console cache:pool:clear --all + + - name: Start PHP Development Server + env: + APP_ENV: 'prod' + DATABASE_URL: ${{ matrix.database_url }} + DATABASE_SERVER_VERSION: ${{ matrix.database_server_version }} + DATABASE_CHARSET: ${{ matrix.database_charset }} + MAILER_URL: 'smtp://127.0.0.1:1025' + ECCUBE_AUTH_MAGIC: ${{ env.ECCUBE_AUTH_MAGIC }} + run: | + ## EC-CUBE は public-dir="." のため front controller はルートの index.php。 + ## codeception/router.php は GET に対し false を返すため、php-S の静的フォールバック挙動 + ## (PHP version 依存) によっては app ルートが 404 になる。discovery/checkout は API のみで + ## 静的アセット配信が不要なので、front controller の index.php を直接 router にして確実に + ## kernel へ通す (symfony の `php -S -t public public/index.php` と同等)。 + php -S 127.0.0.1:8000 index.php > "${RUNNER_TEMP}/php-server.log" 2>&1 & + for _ in $(seq 1 30); do curl -sf http://127.0.0.1:8000/ >/dev/null && break; sleep 1; done + + ## ── discovery スモーク (RFC 8615・一次仕様準拠の最小チェック) ── + - name: Smoke - UCP discovery (/.well-known/ucp, 常時公開) + run: | + code=$(curl -s -o /tmp/ucp.json -w '%{http_code}' http://127.0.0.1:8000/.well-known/ucp) + echo "HTTP ${code}"; echo '--- body ---'; cat /tmp/ucp.json; echo + if [ "${code}" != "200" ]; then echo '--- php server log ---'; tail -80 "${RUNNER_TEMP}/php-server.log"; exit 1; fi + jq -e '.ucp.version' /tmp/ucp.json # version (YYYY-MM-DD) 必須 + jq -e '.ucp.capabilities' /tmp/ucp.json # capabilities 宣言 + jq -e '.ucp.payment_handlers' /tmp/ucp.json # payment_handlers レジストリ 必須 + ## SamplePayment44 の UCP ハンドラが自動広告される (登録するだけで discovery に載る) + jq -e '.ucp.payment_handlers["dev.ucp.payment.card"][0].id == "dev.ucp.payment.card"' /tmp/ucp.json + jq -e '.ucp.payment_handlers["dev.ucp.payment.card"][0].version' /tmp/ucp.json # entity version 必須 + ## signing_keys は公開鍵限定 (秘密鍵パラメータ d を含まない) + ! jq -e '.signing_keys[]?.d' /tmp/ucp.json # d があれば異常 (秘密鍵混入) + + - name: Smoke - ACP discovery (/.well-known/acp.json, acp_checkout_enabled ゲート) + run: | + code=$(curl -s -o /tmp/acp.json -w '%{http_code}' http://127.0.0.1:8000/.well-known/acp.json) + echo "HTTP ${code}"; echo '--- body ---'; cat /tmp/acp.json; echo + if [ "${code}" != "200" ]; then echo '--- php server log ---'; tail -80 "${RUNNER_TEMP}/php-server.log"; exit 1; fi + test "$(jq -r '.protocol.name' /tmp/acp.json)" = "acp" # protocol.name=acp + ## merchant_id は MUST NOT (混入していたら異常) + ! jq -e '.. | .merchant_id? // empty' /tmp/acp.json + + - name: Dump server log on failure + if: failure() + run: tail -120 "${RUNNER_TEMP}/php-server.log" || true + + - name: Upload logs + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: integration-smoke-${{ matrix.db }}-logs + path: | + var/log/ + ${{ runner.temp }}/php-server.log + + ## ─────────────────────────────────────────────────────────────────────── + ## Job B: client_credentials トークンを実発行 (api4#188) → PHP エージェントシミュレータで + ## OAuth2 認証 + checkout セッション (create/update/get) を検証。 + ## complete (決済実行) は決済情報を要するため inputs.run_payment で sub-gate する + ## (既定 false でも認証 + セッション構築までは常に検証する = #188 の結合を CI で保証)。 + ## ─────────────────────────────────────────────────────────────────────── + checkout-e2e: + name: Checkout E2E (OAuth2 認証 + セッション検証 / complete は run_payment 指定時のみ) + needs: integration-smoke + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + php: [ '8.3' ] + db: [ pgsql ] + include: + - db: pgsql + database_url: postgres://postgres:password@127.0.0.1:5432/eccube_db + database_server_version: 18 + database_charset: utf8 + + services: + mailcatcher: + image: schickling/mailcatcher + ports: + - 1080:1080 + - 1025:1025 + + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + persist-credentials: false + + - name: Setup PostgreSQL + uses: ankane/setup-postgres@6d3ffa1aa7498a42b79e9f9f5838b99971839300 # v1 + with: + postgres-version: '18' + user: postgres + - name: Configure PostgreSQL + run: | + for _ in $(seq 1 30); do pg_isready -h 127.0.0.1 && break; sleep 1; done + psql -h 127.0.0.1 -U postgres -c "ALTER USER postgres PASSWORD 'password'" + + - name: Setup PHP + uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2 + with: + php-version: ${{ matrix.php }} + # 追加拡張は指定しない (必要な拡張は setup-php 既定で揃う)。共有レンタルサーバー相当の環境を + # 再現するため sodium は意図的に外す: composer.json の config.platform.ext-sodium だけで + # api44 (lcobucci/jwt の ext-sodium 要求) が導入でき、実行時も RSA/openssl 経路で動作することを + # 検証する (#6827)。redis も本 E2E は FilesystemAdapter のため未使用。 + + - name: Initialize Composer + uses: ./.github/actions/composer + + - name: Generate ECCUBE_AUTH_MAGIC + run: echo "ECCUBE_AUTH_MAGIC=$(openssl rand -hex 32)" >> $GITHUB_ENV + + - name: Setup to EC-CUBE + plugins + env: + APP_ENV: 'prod' + DATABASE_URL: ${{ matrix.database_url }} + DATABASE_SERVER_VERSION: ${{ matrix.database_server_version }} + DATABASE_CHARSET: ${{ matrix.database_charset }} + ECCUBE_AUTH_MAGIC: ${{ env.ECCUBE_AUTH_MAGIC }} + ## untrusted な可能性のある dispatch 入力は env 経由で受ける + API4_REF: ${{ inputs.api4_ref || 'feature/agentic-commerce' }} + SAMPLE_REF: ${{ inputs.sample_ref || 'feature/agentic-commerce' }} + ## 公開フォークの VCS 解決に GitHub トークンを渡す (レート制限回避) + COMPOSER_AUTH: '{"github-oauth": {"github.com": "${{ secrets.GITHUB_TOKEN }}"}}' + run: | + echo "APP_ENV=${APP_ENV}" > .env + echo "ECCUBE_AUTH_MAGIC=${ECCUBE_AUTH_MAGIC}" >> .env + ## api4 (#188) の OAuth2 encryption_key 既定は プレースホルダのため実値を設定 + echo "ECCUBE_OAUTH2_ENCRYPTION_KEY=$(openssl rand -base64 32)" >> .env + bin/console doctrine:database:create --env=dev + bin/console doctrine:schema:create --env=dev + bin/console eccube:fixtures:load --env=dev + ## fork デフォルトブランチの旧名問題を避けるため path リポジトリで取り込む (Job A と同方針) + git clone --depth 1 --branch "${API4_REF}" "${API44_VCS}" "${RUNNER_TEMP}/api44" + git clone --depth 1 --branch "${SAMPLE_REF}" "${SAMPLEPAYMENT44_VCS}" "${RUNNER_TEMP}/samplepayment44" + ## api44 は extra.id を持たないため path インストール用にダミー id を注入 (Job A と同方針) + jq '.extra.id = 990044' "${RUNNER_TEMP}/api44/composer.json" > "${RUNNER_TEMP}/api44/composer.json.tmp" + mv "${RUNNER_TEMP}/api44/composer.json.tmp" "${RUNNER_TEMP}/api44/composer.json" + composer config repositories.api44 path "${RUNNER_TEMP}/api44" + composer config repositories.samplepayment44 path "${RUNNER_TEMP}/samplepayment44" + bin/console eccube:composer:require "ec-cube/api44:*" + bin/console eccube:composer:require "ec-cube/samplepayment44:*" + bin/console eccube:plugin:enable --code=Api44 + bin/console eccube:plugin:enable --code=SamplePayment44 + ## sample 決済を全配送業者へ allowed にする (PaymentManager は PaymentOption を作らないため)。 + ## これがないと findAllowedPayments に sample CreditCard が出ず、エージェント決済の Payment 解決が空になる。 + bin/console dbal:run-sql "INSERT INTO dtb_payment_option (delivery_id, payment_id, discriminator_type) SELECT d.id, p.id, 'paymentoption' FROM dtb_delivery d CROSS JOIN dtb_payment p WHERE p.visible = true AND NOT EXISTS (SELECT 1 FROM dtb_payment_option po WHERE po.delivery_id = d.id AND po.payment_id = p.id)" + bin/console cache:clear + ## フラグ反転後に result cache pool を消す (acp.json/ucp ゲートの stale 回避) + bin/console dbal:run-sql "UPDATE dtb_base_info SET acp_checkout_enabled = true, ucp_checkout_enabled = true" + bin/console cache:pool:clear --all + + ## api4#188: OAuth2 鍵ペア生成 + client_credentials クライアント作成。 + ## PluginManager は鍵を生成しないため league のコマンドで明示生成する。 + - name: Setup OAuth2 keypair & client (client_credentials) + env: + APP_ENV: 'prod' + DATABASE_URL: ${{ matrix.database_url }} + DATABASE_SERVER_VERSION: ${{ matrix.database_server_version }} + DATABASE_CHARSET: ${{ matrix.database_charset }} + run: | + mkdir -p app/PluginData/Api44/oauth + ## インストール時に鍵が生成済みのことがあるため --overwrite で確実に再生成する + bin/console league:oauth2-server:generate-keypair --overwrite + ## create-client の引数は の順 (name が先頭)。 + ## identifier/secret は英数字 (admin フォームの規約・ハイフン由来の不具合回避)。 + bin/console league:oauth2-server:create-client \ + --grant-type=client_credentials \ + --scope=acp:checkout --scope=ucp:checkout \ + agentE2e agente2eclient agente2esecret + + - name: Start PHP Development Server + env: + APP_ENV: 'prod' + DATABASE_URL: ${{ matrix.database_url }} + DATABASE_SERVER_VERSION: ${{ matrix.database_server_version }} + DATABASE_CHARSET: ${{ matrix.database_charset }} + MAILER_URL: 'smtp://127.0.0.1:1025' + ECCUBE_AUTH_MAGIC: ${{ env.ECCUBE_AUTH_MAGIC }} + run: | + ## front controller の index.php を直接 router にして確実に kernel へ通す (Job A と同方針) + php -S 127.0.0.1:8000 index.php > "${RUNNER_TEMP}/php-server.log" 2>&1 & + for _ in $(seq 1 30); do curl -sf http://127.0.0.1:8000/ >/dev/null && break; sleep 1; done + + ## /token (client_credentials) で実 JWT を取得 (api4#188 が機能していることの実証) + - name: Issue OAuth2 access token (client_credentials) + id: token + run: | + resp=$(curl -s -X POST http://127.0.0.1:8000/token \ + -d grant_type=client_credentials \ + -d client_id=agente2eclient \ + -d client_secret=agente2esecret \ + --data-urlencode 'scope=acp:checkout ucp:checkout') + echo "--- /token response ---"; echo "$resp" | jq . || echo "$resp" + tok=$(echo "$resp" | jq -r '.access_token // empty') + if [ -z "$tok" ]; then + echo '::error::client_credentials token issuance failed (api4#188)' + tail -80 "${RUNNER_TEMP}/php-server.log"; exit 1 + fi + echo "token=${tok}" >> "$GITHUB_OUTPUT" + + - name: Run ACP agent simulator (auth + session; complete は run_payment 時のみ) + env: + BASE_URL: 'http://127.0.0.1:8000' + AGENT_E2E_TOKEN: ${{ steps.token.outputs.token }} + ## fixtures の ProductClass id=1 は visible=0 のダミー規格で purchase flow が明細除去するため、 + ## 購入可能な規格 (id=2) を使う。 + AGENT_E2E_ITEM_ID: '2' + ## complete (決済実行) は #3 決済ハンドラ landing 後に run_payment=true で有効化 + AGENT_E2E_PAYMENT_READY: ${{ inputs.run_payment }} + run: php e2e/agent/acp-checkout.php + + ## UCP は RFC 9421 署名 (requireSignature=false で署名なし可) のため OAuth2 トークン不要。 + - name: Run UCP agent simulator (session; complete は run_payment 時のみ) + env: + BASE_URL: 'http://127.0.0.1:8000' + AGENT_E2E_ITEM_ID: '2' + AGENT_E2E_PAYMENT_READY: ${{ inputs.run_payment }} + run: php e2e/agent/ucp-checkout.php + + - name: Dump server log on failure + if: failure() + run: tail -120 "${RUNNER_TEMP}/php-server.log" || true + + - name: Upload logs + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: checkout-e2e-${{ matrix.db }}-logs + path: | + var/log/ + ${{ runner.temp }}/php-server.log diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 78b275e2dcf..3c439cf157a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -37,6 +37,12 @@ jobs: # PR/push では最新 PHP のみ。全バージョンは weekly-test.yml が週次で回す with: php-versions: "['8.5']" + agentic-commerce-e2e: + # plugin-test と並列で実行する (同じ needs)。 + needs: [ unit-test, e2e-test ] + # 本体 ↔ eccube-api4 ↔ sample-payment-plugin の結合 E2E。 + # 依存プラグインは fork の feature/agentic-commerce を既定参照 (org 移行後に別 PR で差し替え)。 + uses: ./.github/workflows/agentic-commerce-e2e.yml dockerbuild: uses: ./.github/workflows/dockerbuild.yml permissions: @@ -59,7 +65,7 @@ jobs: # 全ジョブを直接 needs する。チェーン依存(success←throttling←plugin-test←unit-test)だと # 上流の failure が各ホップで skipped に化けて末端へ届かず、必須チェック success が # skipped=パス扱いとなって赤テストのまま auto-merge されてしまうため(#6840)。 - needs: [ rector, phpstan, php-cs-fixer, unit-test, e2e-test, plugin-test, e2e-test-throttling, dockerbuild ] + needs: [ rector, phpstan, php-cs-fixer, unit-test, e2e-test, plugin-test, e2e-test-throttling, dockerbuild, agentic-commerce-e2e ] # 上流が失敗しても success 自体は必ず実行する(skip させない) if: ${{ !cancelled() }} runs-on: ubuntu-latest diff --git a/app/config/eccube/packages/doctrine.yaml b/app/config/eccube/packages/doctrine.yaml index f8805bcbe44..cf9c34df96a 100644 --- a/app/config/eccube/packages/doctrine.yaml +++ b/app/config/eccube/packages/doctrine.yaml @@ -30,6 +30,12 @@ doctrine: auto_generate_proxy_classes: '%kernel.debug%' naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware auto_mapping: true + # EccubeBundle の Entity は Kernel::addEntityExtensionPass が TraitProxyAttributeDriver で + # 明示登録する。auto_mapping が同じ src/Eccube/Entity を素の AttributeDriver でも登録すると、 + # そちらが Entity ソースを無条件に require_once するため、Proxy (app/proxy/entity) を + # ロード済みの状態で "Cannot redeclare class" となる (Entity の class_exists ガード全廃後). + mappings: + EccubeBundle: false controller_resolver: auto_mapping: false dql: diff --git a/app/config/eccube/services.yaml b/app/config/eccube/services.yaml index ae666313f51..7c215c05483 100644 --- a/app/config/eccube/services.yaml +++ b/app/config/eccube/services.yaml @@ -278,40 +278,6 @@ services: Eccube\Service\AgentCommerce\Security\AgentCommerceMessageSignerInterface: alias: Eccube\Service\AgentCommerce\Security\UcpMessageSigner - # Agent Commerce: Product Feed / Catalog 共通基盤 (#6794) - Eccube\Service\AgentCommerce\Catalog\ProductReferenceResolverInterface: - alias: Eccube\Service\AgentCommerce\Catalog\ProductReferenceResolver - - Eccube\Service\AgentCommerce\Catalog\CatalogProviderInterface: - alias: Eccube\Service\AgentCommerce\Catalog\CatalogProvider - - # Agent Commerce: ACP Product Feed (push) (#6794) - Eccube\Service\AgentCommerce\Catalog\Acp\AcpFeedValidator: - arguments: - $schemaPath: '%kernel.project_dir%/src/Eccube/Resource/AgentCommerce/Acp/schema.feed.json' - - Eccube\Service\AgentCommerce\Catalog\Acp\AcpFeedClient: - arguments: - $baseUrl: '%env(ECCUBE_AGENT_COMMERCE_ACP_FEED_BASE_URL)%' - $apiKey: '%env(ECCUBE_AGENT_COMMERCE_ACP_FEED_API_KEY)%' - - Eccube\Service\AgentCommerce\Catalog\Acp\AcpFeedClientInterface: - alias: Eccube\Service\AgentCommerce\Catalog\Acp\AcpFeedClient - - # Agent Commerce: UCP Catalog (pull / REST) (#6794) - Eccube\Service\AgentCommerce\Catalog\Ucp\UcpCatalogCache: - arguments: - $cacheDir: '%kernel.cache_dir%' - - # Agent Commerce: UCP Discovery (/.well-known/ucp) (#6794 / #6777) - # payment_handlers は決済ハンドラプラグインが tagged service で寄与する (既定は空 {}). - Eccube\Service\AgentCommerce\Discovery\EmptyPaymentHandlerRegistry: - arguments: - $registries: !tagged_iterator eccube.agent_commerce.payment_handler_registry - - Eccube\Service\AgentCommerce\Discovery\PaymentHandlerRegistryInterface: - alias: Eccube\Service\AgentCommerce\Discovery\EmptyPaymentHandlerRegistry - # Agent Commerce CheckoutSession 中核 (#6777 Phase 1b) Eccube\Service\AgentCommerce\Fulfillment\FulfillmentOptionMapperInterface: alias: Eccube\Service\AgentCommerce\Fulfillment\StandardFulfillmentOptionMapper @@ -325,13 +291,11 @@ services: arguments: $accessTokenHandler: '@?Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface' - # Agent Commerce 決済ハンドラ (#6574 UCP / #6776 ACP) - # 具象ハンドラは決済プラグインが agent_commerce.payment_handler タグで寄与する。 - _instanceof: - Eccube\Service\AgentCommerce\Payment\AgentCheckoutPaymentHandlerInterface: - tags: ['agent_commerce.payment_handler'] - - # 決済ハンドラレジストリ。具象ハンドラ (決済プラグイン) は agent_commerce.payment_handler タグで寄与する。 + # Agent Commerce 決済ハンドラレジストリ (#6574 UCP / #6776 ACP) + # 具象ハンドラ (決済プラグイン) は agent_commerce.payment_handler タグで寄与する。 + # タグ付けは Kernel::build() の registerForAutoconfiguration で行う (PaymentMethodInterface と同様)。 + # services.yaml の _instanceof はファイルスコープのため、Plugin\ glob (services.php・#6915) で + # 登録される決済プラグインの具象ハンドラには届かない。 Eccube\Service\AgentCommerce\Payment\AgentCheckoutPaymentHandlerRegistry: arguments: $handlers: !tagged_iterator agent_commerce.payment_handler @@ -356,3 +320,42 @@ services: Eccube\Service\AgentCommerce\Ucp\Signature\UcpSignatureSubscriber: arguments: $requireSignature: false + + # Agent Commerce: Product Feed / Catalog 共通基盤 (#6794) + Eccube\Service\AgentCommerce\Catalog\ProductReferenceResolverInterface: + alias: Eccube\Service\AgentCommerce\Catalog\ProductReferenceResolver + + Eccube\Service\AgentCommerce\Catalog\CatalogProviderInterface: + alias: Eccube\Service\AgentCommerce\Catalog\CatalogProvider + + # Agent Commerce: ACP Product Feed (push) (#6794) + Eccube\Service\AgentCommerce\Catalog\Acp\AcpFeedValidator: + arguments: + $schemaPath: '%kernel.project_dir%/src/Eccube/Resource/AgentCommerce/Acp/schema.feed.json' + + Eccube\Service\AgentCommerce\Catalog\Acp\AcpFeedClient: + arguments: + $baseUrl: '%env(ECCUBE_AGENT_COMMERCE_ACP_FEED_BASE_URL)%' + $apiKey: '%env(ECCUBE_AGENT_COMMERCE_ACP_FEED_API_KEY)%' + + Eccube\Service\AgentCommerce\Catalog\Acp\AcpFeedClientInterface: + alias: Eccube\Service\AgentCommerce\Catalog\Acp\AcpFeedClient + + # Agent Commerce: UCP Catalog (pull / REST) (#6794) + Eccube\Service\AgentCommerce\Catalog\Ucp\UcpCatalogCache: + arguments: + $cacheDir: '%kernel.cache_dir%' + + # Agent Commerce: UCP Discovery (/.well-known/ucp) (#6794 / #6777) + # payment_handlers は決済ハンドラプラグインが tagged service で寄与する (既定は空 {}). + Eccube\Service\AgentCommerce\Discovery\EmptyPaymentHandlerRegistry: + arguments: + $registries: !tagged_iterator eccube.agent_commerce.payment_handler_registry + + Eccube\Service\AgentCommerce\Discovery\PaymentHandlerRegistryInterface: + alias: Eccube\Service\AgentCommerce\Discovery\EmptyPaymentHandlerRegistry + + # 登録済み UCP 決済ハンドラ (agent_commerce.payment_handler) から payment_handlers を自動広告する。 + # プラグインは UCP ハンドラを 1 つ登録するだけで discovery にも載る (ゼロ設定)。 + Eccube\Service\AgentCommerce\Discovery\UcpPaymentHandlerDiscoveryRegistry: + tags: ['eccube.agent_commerce.payment_handler_registry'] diff --git a/app/config/eccube/services_test.yaml b/app/config/eccube/services_test.yaml index 838a1e977e8..b22eeba0826 100644 --- a/app/config/eccube/services_test.yaml +++ b/app/config/eccube/services_test.yaml @@ -99,6 +99,14 @@ services: Eccube\Service\AgentCommerce\Idempotency\AgentCheckoutIdempotencyStore: autowire: true public: true + Eccube\Service\AgentCommerce\Payment\AgentCheckoutPaymentHandlerRegistry: + autowire: true + public: true + arguments: + $handlers: !tagged_iterator agent_commerce.payment_handler + Eccube\Service\AgentCommerce\StorefrontUrlResolver: + autowire: true + public: true # ACP プロトコル層 (#6776)。consumer (controller) は存在するが、単体テストが直接取得するため public 化。 Eccube\Service\AgentCommerce\Acp\AcpCheckoutSessionMapper: autowire: true @@ -115,13 +123,7 @@ services: Eccube\Service\AgentCommerce\Acp\AcpMessageSigner: autowire: true public: true - # ACP の OAuth2 認証検証用。eccube-api4 非依存のスタブハンドラを AccessTokenHandlerInterface に束ねる。 - Eccube\Tests\Service\AgentCommerce\Stub\InMemoryAccessTokenHandler: - public: true - Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface: - alias: Eccube\Tests\Service\AgentCommerce\Stub\InMemoryAccessTokenHandler - public: true - # UCP checkout (#6574) サービス群 (upstream/4.4 で #6837 merge 済)。テストからコンテナ取得するため public 化する。 + # UCP checkout (#6574) サービス群。テストからコンテナ取得するため public 化する。 Eccube\Service\AgentCommerce\Ucp\UcpCheckoutSessionMapper: autowire: true public: true @@ -142,11 +144,9 @@ services: public: true arguments: $allowedDomains: [] - Eccube\Service\AgentCommerce\Payment\AgentCheckoutPaymentHandlerRegistry: - autowire: true + # ACP の OAuth2 認証検証用。eccube-api4 非依存のスタブハンドラを AccessTokenHandlerInterface に束ねる。 + Eccube\Tests\Service\AgentCommerce\Stub\InMemoryAccessTokenHandler: public: true - arguments: - $handlers: !tagged_iterator agent_commerce.payment_handler - Eccube\Service\AgentCommerce\StorefrontUrlResolver: - autowire: true + Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface: + alias: Eccube\Tests\Service\AgentCommerce\Stub\InMemoryAccessTokenHandler public: true diff --git a/e2e/agent/README.md b/e2e/agent/README.md new file mode 100644 index 00000000000..cc94fe9873d --- /dev/null +++ b/e2e/agent/README.md @@ -0,0 +1,67 @@ +# Agentic Commerce E2E — PHP エージェントシミュレータ + +本体 (ACP/UCP) ↔ [eccube-api4](https://github.com/EC-CUBE/eccube-api4) (OAuth2) ↔ sample-payment-plugin (決済ハンドラ) の結合を、 +「AI エージェント役」のクライアントが **外部 HTTP** で検証するハーネス。CI からは +[`.github/workflows/agentic-commerce-e2e.yml`](../../.github/workflows/agentic-commerce-e2e.yml) (`workflow_dispatch`) が呼び出す。 + +## 構成 + +| ファイル | 役割 | +|---|---| +| `acp-checkout.php` | ACP discovery スモーク + checkout フロー (create→update→get→complete)。OAuth2 Bearer (api4) 認証 | +| `ucp-checkout.php` | UCP checkout フロー (create→get→update→complete)。RFC 9421 署名は CI では不要 (`ucp_checkout_enabled` ゲートのみ・api4 非依存) | + +## 2 フェーズと skip ゲート + +### `acp-checkout.php` (ACP) + +1. **discovery スモーク** — 常に実行。`/.well-known/ucp` (常時公開) と `/.well-known/acp.json` + (`acp_checkout_enabled` ゲート) の生存・形状 (秘密鍵非混入・`merchant_id` 非混入等) を検証。 + api4 / 決済ハンドラに非依存なので **day 1 から green**。 +2. **checkout フロー** — `AGENT_E2E_TOKEN` がある時のみ。OAuth2 Bearer で ACP 5 エンドポイントを叩く。 + トークン発行は **eccube-api4#188 (client_credentials + `acp:`/`ucp:` scope)** と決済ハンドラの + landing が前提。未 landing の間は `AGENT_E2E_TOKEN` が空のまま → フェーズ 1 のみ実行して正常終了。 + +### `ucp-checkout.php` (UCP) + +1. **checkout セッション** — 常に実行。`ucp_checkout_enabled` ゲートのみで、UCP のインバウンド認証 + (RFC 9421 署名) は CI では `requireSignature=false` のため署名なしで通る (api4 非依存)。 + create → get → update を検証。 +2. **complete シナリオ** — `AGENT_E2E_PAYMENT_READY=true` の時のみ。sample-payment の UCP 決済ハンドラ + 経由で成功 / escalation / 拒否の 3 シナリオを検証。 + +## ローカル実行 + +```bash +# 1. フラグを立て result cache pool を消す (BaseInfo は result cache されるため必須) +bin/console dbal:run-sql "UPDATE dtb_base_info SET acp_checkout_enabled = 1, ucp_checkout_enabled = 1" +bin/console cache:pool:clear --all + +# 2. ビルトインサーバ起動 (EC-CUBE は public-dir="." のため codeception/router.php を router にする) +php -S 127.0.0.1:8000 codeception/router.php & + +# 3. ACP discovery-only (トークン無し) +BASE_URL=http://127.0.0.1:8000 php e2e/agent/acp-checkout.php + +# 3'. ACP checkout 込み (api4#188 + 決済ハンドラ landing 後) +BASE_URL=http://127.0.0.1:8000 AGENT_E2E_TOKEN= php e2e/agent/acp-checkout.php + +# 4. UCP checkout セッションのみ (署名なし・api4 非依存) +BASE_URL=http://127.0.0.1:8000 php e2e/agent/ucp-checkout.php + +# 4'. UCP complete 込み (決済ハンドラ landing 後) +BASE_URL=http://127.0.0.1:8000 AGENT_E2E_PAYMENT_READY=true php e2e/agent/ucp-checkout.php +``` + +## env + +| 変数 | 既定 | 説明 | +|---|---|---| +| `BASE_URL` | `http://127.0.0.1:8000` | 稼働中サーバ | +| `AGENT_E2E_TOKEN` | (空) | OAuth2 Bearer (ACP のみ)。空ならフェーズ 2 を skip | +| `AGENT_E2E_PAYMENT_READY` | `false` | `true` の時のみ complete (決済) を実行。ACP/UCP 共通 | +| `AGENT_E2E_ITEM_ID` | ACP=`1` / UCP=`2` | checkout で使う ProductClass id (UCP の `1` は visible=0 の規格) | + +> **注意**: このブランチ (`feature/agentic-commerce-e2e`) は ACP/UCP/feed/共通基盤を 1 本に集約した +> **テスト専用の統合ブランチ**。各機能は個別 PR (#6802/#6815/#6825/#6837/#6843) で 4.4 へマージされる。 +> E2E 成果物は 4.4 マージ後に専用 PR で移送する。 diff --git a/e2e/agent/acp-checkout.php b/e2e/agent/acp-checkout.php new file mode 100644 index 00000000000..c480a10c25c --- /dev/null +++ b/e2e/agent/acp-checkout.php @@ -0,0 +1,272 @@ +#!/usr/bin/env php + $baseUrl.'/', 'timeout' => 30]); + +$passed = 0; + +/** 成功を表示してカウント。 */ +function ok(string $msg): void +{ + global $passed; + ++$passed; + fwrite(STDOUT, " \033[32m✓\033[0m {$msg}\n"); +} + +/** 失敗を表示して非ゼロ終了 (どの要件で落ちたかをログだけで追えるようにする)。 */ +function fail(string $msg): never +{ + fwrite(STDERR, " \033[31m✗ FAIL:\033[0m {$msg}\n"); + exit(1); +} + +function assertTrue(bool $cond, string $msg): void +{ + $cond ? ok($msg) : fail($msg); +} + +function section(string $title): void +{ + fwrite(STDOUT, "\n\033[1m{$title}\033[0m\n"); +} + +// ───────────────────────────────────────────────────────────────────────── +// フェーズ 1: discovery スモーク (常時実行) +// ───────────────────────────────────────────────────────────────────────── +section('Phase 1: discovery smoke'); + +// UCP discovery (/.well-known/ucp・常時公開・RFC 8615) +try { + $res = $client->request('GET', '.well-known/ucp'); + assertTrue(200 === $res->getStatusCode(), 'GET /.well-known/ucp returns 200'); + $ucp = $res->toArray(); + assertTrue(isset($ucp['ucp']['version']), 'UCP profile has ucp.version (YYYY-MM-DD)'); + assertTrue(isset($ucp['ucp']['capabilities']), 'UCP profile declares ucp.capabilities'); + // signing_keys は公開鍵限定 (秘密鍵パラメータ "d" を含まない・MUST) + $hasPrivate = false; + foreach ($ucp['signing_keys'] ?? [] as $jwk) { + if (isset($jwk['d'])) { + $hasPrivate = true; + break; + } + } + assertTrue(!$hasPrivate, 'UCP signing_keys[] contains no private key parameter "d"'); +} catch (\Throwable $e) { + fail('UCP discovery: '.$e->getMessage()); +} + +// ACP discovery (/.well-known/acp.json・acp_checkout_enabled ゲート) +try { + $res = $client->request('GET', '.well-known/acp.json'); + assertTrue(200 === $res->getStatusCode(), 'GET /.well-known/acp.json returns 200'); + $acp = $res->toArray(); + assertTrue(($acp['protocol']['name'] ?? null) === 'acp', 'ACP discovery protocol.name == "acp"'); + // merchant_id は MUST NOT (混入していないこと) + assertTrue(!containsKey($acp, 'merchant_id'), 'ACP discovery MUST NOT contain merchant_id'); +} catch (\Throwable $e) { + fail('ACP discovery: '.$e->getMessage()); +} + +// ───────────────────────────────────────────────────────────────────────── +// フェーズ 2: checkout フロー (AGENT_E2E_TOKEN がある時のみ) +// ───────────────────────────────────────────────────────────────────────── +if ('' === $token) { + fwrite(STDOUT, "\n\033[33m⏸ Phase 2 (checkout) skipped:\033[0m AGENT_E2E_TOKEN 未設定 (api4#188 + 決済ハンドラ landing 待ち)\n"); + fwrite(STDOUT, "\n\033[32mPASS\033[0m ({$passed} assertions, discovery-only)\n"); + exit(0); +} + +section('Phase 2: ACP checkout flow (OAuth2 authenticated)'); +runCheckout($client, $token, $itemId, $paymentReady); + +$mode = $paymentReady ? 'auth + session + complete' : 'auth + session (complete は #3 待ち)'; +fwrite(STDOUT, "\n\033[32mPASS\033[0m ({$passed} assertions, {$mode})\n"); +exit(0); + +// ───────────────────────────────────────────────────────────────────────── +// helpers +// ───────────────────────────────────────────────────────────────────────── + +/** 連想配列を再帰探索し、指定キーが存在するかを判定する。 */ +function containsKey(mixed $data, string $key): bool +{ + if (!is_array($data)) { + return false; + } + if (array_key_exists($key, $data)) { + return true; + } + foreach ($data as $v) { + if (containsKey($v, $key)) { + return true; + } + } + + return false; +} + +/** + * ACP checkout 5 エンドポイントを順に叩く (create→update→get→complete)。 + * + * TODO(api4#188 + 決済ハンドラ): トークン発行が有効化されたら payload を + * {@link \Eccube\Service\AgentCommerce\Acp\AcpCheckoutSessionMapper} の契約に対して + * 実データで突き合わせる (本関数の payload は ACP 2026-04-17 spec ベースの暫定形)。 + */ +function runCheckout(HttpClientInterface $client, string $token, int $itemId, bool $paymentReady): void +{ + $auth = ['Authorization' => 'Bearer '.$token, 'Content-Type' => 'application/json']; + $idempotencyKey = bin2hex(random_bytes(16)); + + // 1. create (POST /acp/checkout_sessions) + $createBody = [ + 'line_items' => [['id' => (string) $itemId, 'quantity' => 1]], + 'fulfillment_details' => [ + 'name' => ['first_name' => '太郎', 'last_name' => '山田'], + 'contact' => ['email' => 'agent-e2e@example.com', 'phone' => '0312345678'], + 'address' => [ + 'line_one' => '1-1-1', + 'city' => '千代田区', + 'state' => '東京都', + 'postal_code' => '1000001', + 'country' => 'JP', + ], + ], + ]; + $res = $client->request('POST', 'acp/checkout_sessions', [ + 'headers' => $auth + ['Idempotency-Key' => $idempotencyKey], + 'json' => $createBody, + ]); + assertTrue(in_array($res->getStatusCode(), [200, 201], true), 'POST /acp/checkout_sessions returns 200/201'); + $session = $res->toArray(false); + $sessionId = $session['id'] ?? null; + assertTrue(is_string($sessionId) && '' !== $sessionId, 'create response has session id'); + // 金額は minor unit 整数 (JPY はゼロデシマル) + foreach ($session['totals'] ?? [] as $total) { + assertTrue(is_int($total['amount'] ?? null), "totals[].amount is minor-unit integer ({$total['type']})"); + } + + // 2. update (POST /acp/checkout_sessions/{id}) — 冪等キーは新規 + $res = $client->request('POST', 'acp/checkout_sessions/'.$sessionId, [ + 'headers' => $auth + ['Idempotency-Key' => bin2hex(random_bytes(16))], + 'json' => $createBody, + ]); + assertTrue(200 === $res->getStatusCode(), 'POST /acp/checkout_sessions/{id} (update) returns 200'); + + // 3. get (GET /acp/checkout_sessions/{id}) + $res = $client->request('GET', 'acp/checkout_sessions/'.$sessionId, ['headers' => $auth]); + assertTrue(200 === $res->getStatusCode(), 'GET /acp/checkout_sessions/{id} returns 200'); + assertTrue(($res->toArray(false)['id'] ?? null) === $sessionId, 'get returns the same session id'); + + // 4. complete (POST /acp/checkout_sessions/{id}/complete) + // 決済実行は sample-payment 決済ハンドラ (#3) が前提のため、許可された時のみ実行する。 + // 各シナリオは状態が確定するため新規セッションで実行する。 + if (!$paymentReady) { + fwrite(STDOUT, " \033[33m⏸\033[0m complete skipped: AGENT_E2E_PAYMENT_READY!=true (#3 決済ハンドラ待ち)\n"); + + return; + } + + section('Phase 2b: ACP complete scenarios (sample-payment ハンドラ経由)'); + + // (a) 正常系: handler_id 駆動で sample CreditCard ハンドラが与信→売上→確定する。 + $sid = createSession($client, $auth, $createBody); + $body = completeAcp($client, $auth, $sid, ['handler_id' => 'card_tokenized', 'token' => 'e2e-acp-spt-ok', 'provider' => 'sample_payment']); + assertTrue(($body['status'] ?? null) === 'completed', 'complete (ok token) => status "completed"'); + + // (b) 3DS: 中断 (authentication_required) → authentication_result 付きで再開 → completed。 + // silent passthrough (ハンドラ未通過) なら 3DS は発生しないため、これはハンドラ実行の証跡。 + $sid = createSession($client, $auth, $createBody); + $body = completeAcp($client, $auth, $sid, ['handler_id' => 'card_tokenized', 'token' => 'e2e-acp-spt-3ds', 'provider' => 'sample_payment']); + assertTrue(($body['status'] ?? null) === 'authentication_required', 'complete (3ds token) => "authentication_required" (handler ran)'); + $body = completeAcp($client, $auth, $sid, ['handler_id' => 'card_tokenized', 'token' => 'e2e-acp-spt-3ds', 'provider' => 'sample_payment', 'authentication_result' => ['outcome' => 'authenticated']]); + assertTrue(($body['status'] ?? null) === 'completed', 'complete (3ds resume w/ authentication_result) => "completed"'); + + // (c) 拒否: ハンドラが FAILED を返し、注文は確定しない (ready_for_payment) + messages[]。 + // handler_id が無視され既定 Payment ですり抜けると completed になるため、この負ケースが seam を守る。 + $sid = createSession($client, $auth, $createBody); + $body = completeAcp($client, $auth, $sid, ['handler_id' => 'card_tokenized', 'token' => 'e2e-acp-spt-decline', 'provider' => 'sample_payment']); + assertTrue(($body['status'] ?? null) !== 'completed', 'complete (decline token) => not "completed" (handler rejected)'); + assertTrue(!empty($body['messages']), 'complete (decline token) => business messages[] present'); +} + +/** complete シナリオ用に新規セッションを作成し session id を返す。 */ +function createSession(HttpClientInterface $client, array $auth, array $createBody): string +{ + $res = $client->request('POST', 'acp/checkout_sessions', [ + 'headers' => $auth + ['Idempotency-Key' => bin2hex(random_bytes(16))], + 'json' => $createBody, + ]); + assertTrue(in_array($res->getStatusCode(), [200, 201], true), 'scenario: create checkout session'); + $sid = $res->toArray(false)['id'] ?? null; + assertTrue(is_string($sid) && '' !== $sid, 'scenario: created session has id'); + + return $sid; +} + +/** + * complete を呼び、HTTP 200 (2 系統エラーは messages[]) を確認してレスポンス body を返す。 + * + * @param array $paymentData + * + * @return array + */ +function completeAcp(HttpClientInterface $client, array $auth, string $sessionId, array $paymentData): array +{ + $res = $client->request('POST', 'acp/checkout_sessions/'.$sessionId.'/complete', [ + 'headers' => $auth + ['Idempotency-Key' => bin2hex(random_bytes(16))], + 'json' => ['payment_data' => $paymentData], + ]); + $status = $res->getStatusCode(); + $raw = $res->getContent(false); + if (200 !== $status) { + // 失敗時は HTTP ステータスとレスポンス本体を出力して原因 (payment_handler_not_found / 500 等) を可視化する。 + fwrite(STDERR, " \033[33m↳ complete HTTP {$status}\033[0m: ".substr($raw, 0, 1000)."\n"); + } + assertTrue(200 === $status, 'complete returns HTTP 200 (business outcomes are in messages[])'); + + return json_decode($raw, true) ?: []; +} diff --git a/e2e/agent/ucp-checkout.php b/e2e/agent/ucp-checkout.php new file mode 100644 index 00000000000..3538b30cf7a --- /dev/null +++ b/e2e/agent/ucp-checkout.php @@ -0,0 +1,202 @@ +#!/usr/bin/env php + $baseUrl.'/', 'timeout' => 30]); + +$passed = 0; + +function ok(string $msg): void +{ + global $passed; + ++$passed; + fwrite(STDOUT, " \033[32m✓\033[0m {$msg}\n"); +} + +function fail(string $msg): never +{ + fwrite(STDERR, " \033[31m✗ FAIL:\033[0m {$msg}\n"); + exit(1); +} + +function assertTrue(bool $cond, string $msg): void +{ + $cond ? ok($msg) : fail($msg); +} + +function section(string $title): void +{ + fwrite(STDOUT, "\n\033[1m{$title}\033[0m\n"); +} + +// ───────────────────────────────────────────────────────────────────────── +// UCP checkout payload (ucp.dev v2026-04-08) +// ───────────────────────────────────────────────────────────────────────── +$createBody = [ + 'currency' => 'JPY', + 'line_items' => [['item' => ['id' => (string) $itemId], 'quantity' => 1]], + 'buyer' => [ + 'first_name' => '太郎', + 'last_name' => '山田', + 'email' => 'agent-ucp@example.com', + 'phone_number' => '0312345678', + ], + // 送料計算のため配送先 (postal destination) を与える。address_region は都道府県名で解決される。 + 'fulfillment' => [ + 'destinations' => [[ + 'first_name' => '太郎', + 'last_name' => '山田', + 'postal_code' => '1000001', + 'address_region' => '東京都', + 'address_locality' => '千代田区', + 'street_address' => '1-1-1', + 'phone_number' => '0312345678', + ]], + ], +]; + +// ───────────────────────────────────────────────────────────────────────── +// フェーズ 1: checkout セッション (create → get → update) +// ───────────────────────────────────────────────────────────────────────── +section('Phase 1: UCP checkout session (RFC 9421 署名なし・ucp_checkout_enabled ゲート)'); + +$headers = ['Content-Type' => 'application/json']; + +$res = $client->request('POST', 'ucp/checkout-sessions', [ + 'headers' => $headers + ['Idempotency-Key' => bin2hex(random_bytes(16))], + 'json' => $createBody, +]); +assertTrue(in_array($res->getStatusCode(), [200, 201], true), 'POST /ucp/checkout-sessions returns 200/201'); +$session = $res->toArray(false); +$sessionId = $session['id'] ?? null; +assertTrue(is_string($sessionId) && '' !== $sessionId, 'create response has session id'); +assertTrue(isset($session['ucp']['version']), 'response wraps ucp.version'); +foreach ($session['totals'] ?? [] as $total) { + assertTrue(is_int($total['amount'] ?? null), "totals[].amount is minor-unit integer ({$total['type']})"); +} + +$res = $client->request('GET', 'ucp/checkout-sessions/'.$sessionId, ['headers' => $headers]); +assertTrue(200 === $res->getStatusCode(), 'GET /ucp/checkout-sessions/{id} returns 200'); +assertTrue(($res->toArray(false)['id'] ?? null) === $sessionId, 'get returns the same session id'); + +$res = $client->request('PUT', 'ucp/checkout-sessions/'.$sessionId, [ + 'headers' => $headers + ['Idempotency-Key' => bin2hex(random_bytes(16))], + 'json' => $createBody, +]); +assertTrue(200 === $res->getStatusCode(), 'PUT /ucp/checkout-sessions/{id} (update) returns 200'); + +// ───────────────────────────────────────────────────────────────────────── +// フェーズ 2: complete シナリオ (AGENT_E2E_PAYMENT_READY=true のみ) +// ───────────────────────────────────────────────────────────────────────── +if (!$paymentReady) { + fwrite(STDOUT, "\n\033[33m⏸ complete skipped:\033[0m AGENT_E2E_PAYMENT_READY!=true\n"); + fwrite(STDOUT, "\n\033[32mPASS\033[0m ({$passed} assertions, session only)\n"); + exit(0); +} + +section('Phase 2: UCP complete scenarios (sample-payment ハンドラ経由)'); + +// (a) 正常系: handler_id 駆動で UCP ハンドラが交換→与信→売上→確定。 +$sid = createSession($client, $headers, $createBody); +$body = completeUcp($client, $headers, $sid, 'e2e-ucp-ok'); +assertTrue(($body['status'] ?? null) === 'completed', 'complete (ok token) => status "completed"'); + +// (b) escalation: REQUIRES_ACTION は UCP では requires_escalation。ハンドラが実行された証跡。 +$sid = createSession($client, $headers, $createBody); +$body = completeUcp($client, $headers, $sid, 'e2e-ucp-3ds'); +assertTrue(($body['status'] ?? null) === 'requires_escalation', 'complete (3ds token) => "requires_escalation" (handler ran)'); + +// (c) 拒否: ハンドラが FAILED を返し確定しない (ready_for_complete) + messages[]。 +$sid = createSession($client, $headers, $createBody); +$body = completeUcp($client, $headers, $sid, 'e2e-ucp-decline'); +assertTrue(($body['status'] ?? null) !== 'completed', 'complete (decline token) => not "completed" (handler rejected)'); +assertTrue(!empty($body['messages']), 'complete (decline token) => business messages[] present'); + +fwrite(STDOUT, "\n\033[32mPASS\033[0m ({$passed} assertions, session + complete)\n"); +exit(0); + +// ───────────────────────────────────────────────────────────────────────── +// helpers +// ───────────────────────────────────────────────────────────────────────── + +/** + * @param array $headers + * @param array $createBody + */ +function createSession(HttpClientInterface $client, array $headers, array $createBody): string +{ + $res = $client->request('POST', 'ucp/checkout-sessions', [ + 'headers' => $headers + ['Idempotency-Key' => bin2hex(random_bytes(16))], + 'json' => $createBody, + ]); + assertTrue(in_array($res->getStatusCode(), [200, 201], true), 'scenario: create checkout session'); + $sid = $res->toArray(false)['id'] ?? null; + assertTrue(is_string($sid) && '' !== $sid, 'scenario: created session has id'); + + return $sid; +} + +/** + * complete を呼び、HTTP 200 (2 系統エラーは messages[]) を確認してレスポンス body を返す。 + * + * @param array $headers + * + * @return array + */ +function completeUcp(HttpClientInterface $client, array $headers, string $sessionId, string $token): array +{ + $res = $client->request('POST', 'ucp/checkout-sessions/'.$sessionId.'/complete', [ + 'headers' => $headers + ['Idempotency-Key' => bin2hex(random_bytes(16))], + 'json' => ['payment' => ['instruments' => [[ + 'handler_id' => 'dev.ucp.payment.card', + 'credential' => ['type' => 'card', 'token' => $token], + ]]]], + ]); + $status = $res->getStatusCode(); + $raw = $res->getContent(false); + if (200 !== $status) { + fwrite(STDERR, " \033[33m↳ complete HTTP {$status}\033[0m: ".substr($raw, 0, 1000)."\n"); + } + assertTrue(200 === $status, 'complete returns HTTP 200 (business outcomes are in messages[])'); + + return json_decode($raw, true) ?: []; +} diff --git a/src/Eccube/Kernel.php b/src/Eccube/Kernel.php index 85eca1463c4..8dc950ff1f0 100644 --- a/src/Eccube/Kernel.php +++ b/src/Eccube/Kernel.php @@ -34,6 +34,7 @@ use Eccube\Doctrine\ORM\Mapping\Driver\TraitProxyAttributeDriver; use Eccube\Doctrine\Query\QueryCustomizer; use Eccube\Log\Logger; +use Eccube\Service\AgentCommerce\Payment\AgentCheckoutPaymentHandlerInterface; use Eccube\Service\Payment\PaymentMethodInterface; use Eccube\Service\PurchaseFlow\DiscountProcessor; use Eccube\Service\PurchaseFlow\ItemHolderPostValidator; @@ -268,6 +269,13 @@ protected function build(ContainerBuilder $container): void ->addTag(PaymentMethodPass::PAYMENT_METHOD_TAG); $container->addCompilerPass(new PaymentMethodPass()); + // Agent Commerce 決済ハンドラ (#6574 UCP / #6776 ACP) の拡張。 + // 決済プラグインの具象ハンドラは Plugin\ glob (services.php・#6915) で登録されるため、 + // services.yaml のファイルスコープな _instanceof ではタグが付かない。 + // PaymentMethodInterface と同様にコンテナ全体へ効く registerForAutoconfiguration でタグ付けする。 + $container->registerForAutoconfiguration(AgentCheckoutPaymentHandlerInterface::class) + ->addTag('agent_commerce.payment_handler'); + // PurchaseFlow の拡張 $container->registerForAutoconfiguration(ItemPreprocessor::class) ->addTag(PurchaseFlowPass::ITEM_PREPROCESSOR_TAG); diff --git a/src/Eccube/Service/AgentCommerce/Discovery/EmptyPaymentHandlerRegistry.php b/src/Eccube/Service/AgentCommerce/Discovery/EmptyPaymentHandlerRegistry.php index 56dc29d13b2..825af930613 100644 --- a/src/Eccube/Service/AgentCommerce/Discovery/EmptyPaymentHandlerRegistry.php +++ b/src/Eccube/Service/AgentCommerce/Discovery/EmptyPaymentHandlerRegistry.php @@ -33,6 +33,8 @@ public function __construct(private readonly iterable $registries = []) /** * {@inheritdoc} + * + * @return array>> */ public function collect(): array { diff --git a/src/Eccube/Service/AgentCommerce/Discovery/PaymentHandlerRegistryInterface.php b/src/Eccube/Service/AgentCommerce/Discovery/PaymentHandlerRegistryInterface.php index 6b28f24af92..72c73d337fa 100644 --- a/src/Eccube/Service/AgentCommerce/Discovery/PaymentHandlerRegistryInterface.php +++ b/src/Eccube/Service/AgentCommerce/Discovery/PaymentHandlerRegistryInterface.php @@ -33,9 +33,10 @@ interface PaymentHandlerRegistryInterface * payment_handlers レジストリを連想配列で返す. * * キーは reverse-domain 形式 (例: "dev.ucp.payment.google_pay")、 - * 値は当該決済ハンドラの宣言オブジェクト. + * 値は当該決済ハンドラの宣言オブジェクトの **配列** (UCP profile schema 準拠・各エントリは + * `id`/`version` 必須)。 * - * @return array> reverse-domain キーのレジストリ (既定は空) + * @return array>> reverse-domain キーのレジストリ (既定は空) */ public function collect(): array; } diff --git a/src/Eccube/Service/AgentCommerce/Discovery/UcpPaymentHandlerDiscoveryRegistry.php b/src/Eccube/Service/AgentCommerce/Discovery/UcpPaymentHandlerDiscoveryRegistry.php new file mode 100644 index 00000000000..8e0f2343893 --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Discovery/UcpPaymentHandlerDiscoveryRegistry.php @@ -0,0 +1,62 @@ +handlerRegistry->ucpHandlers() as $handler) { + $handlerId = $handler->getHandlerId(); + $handlers[$handlerId] = [ + [ + 'id' => $handlerId, + 'version' => UcpProfileBuilder::UCP_VERSION, + ], + ]; + } + + return $handlers; + } +} diff --git a/src/Eccube/Service/AgentCommerce/Discovery/UcpProfileBuilder.php b/src/Eccube/Service/AgentCommerce/Discovery/UcpProfileBuilder.php index 6efd0568475..3372cf480ac 100644 --- a/src/Eccube/Service/AgentCommerce/Discovery/UcpProfileBuilder.php +++ b/src/Eccube/Service/AgentCommerce/Discovery/UcpProfileBuilder.php @@ -135,7 +135,7 @@ private function buildCapabilities(): array /** * payment_handlers レジストリを組み立てる (寄与が無ければ空オブジェクト {}). * - * @return array> + * @return array>> */ private function buildPaymentHandlers(): array { diff --git a/tests/Eccube/Tests/Service/AgentCommerce/Discovery/UcpPaymentHandlerDiscoveryRegistryTest.php b/tests/Eccube/Tests/Service/AgentCommerce/Discovery/UcpPaymentHandlerDiscoveryRegistryTest.php new file mode 100644 index 00000000000..160022cbd82 --- /dev/null +++ b/tests/Eccube/Tests/Service/AgentCommerce/Discovery/UcpPaymentHandlerDiscoveryRegistryTest.php @@ -0,0 +1,163 @@ +discoveryRegistry([ + $this->ucpHandler('dev.ucp.payment.card'), + $this->ucpHandler('com.example.wallet'), + ]); + + $collected = $registry->collect(); + + // reverse-domain キーのレジストリ。値はハンドラオブジェクトの配列。 + $this->assertSame(['dev.ucp.payment.card', 'com.example.wallet'], array_keys($collected)); + $this->assertSame([ + ['id' => 'dev.ucp.payment.card', 'version' => UcpProfileBuilder::UCP_VERSION], + ], $collected['dev.ucp.payment.card']); + } + + public function testEntryHasRequiredIdAndVersion(): void + { + $registry = $this->discoveryRegistry([$this->ucpHandler('dev.ucp.payment.card')]); + + $entry = $registry->collect()['dev.ucp.payment.card'][0]; + + // payment_handler は id 必須 (payment_handler schema) / version 必須 (entity schema)。 + $this->assertArrayHasKey('id', $entry, 'payment_handler entry requires "id"'); + $this->assertArrayHasKey('version', $entry, 'payment_handler entry requires "version"'); + $this->assertSame('dev.ucp.payment.card', $entry['id']); + $this->assertSame(UcpProfileBuilder::UCP_VERSION, $entry['version']); + } + + public function testExcludesNonUcpHandlers(): void + { + // 非 UCP (base のみ) のハンドラは discovery の payment_handlers に含めない。 + $registry = $this->discoveryRegistry([$this->nonUcpHandler('legacy_card')]); + + $this->assertSame([], $registry->collect()); + } + + public function testReturnsEmptyWhenNoHandlersRegistered(): void + { + $registry = $this->discoveryRegistry([]); + + $this->assertSame([], $registry->collect()); + } + + /** + * @param list $handlers + */ + private function discoveryRegistry(array $handlers): UcpPaymentHandlerDiscoveryRegistry + { + return new UcpPaymentHandlerDiscoveryRegistry(new AgentCheckoutPaymentHandlerRegistry($handlers)); + } + + private function ucpHandler(string $handlerId): UcpPaymentHandlerInterface + { + return new InMemoryUcpDiscoveryHandler($handlerId); + } + + private function nonUcpHandler(string $handlerId): AgentCheckoutPaymentHandlerInterface + { + return new InMemoryBasePaymentHandler($handlerId); + } +} + +/** + * discovery テスト用の in-memory UCP 決済ハンドラ. + */ +final readonly class InMemoryUcpDiscoveryHandler implements UcpPaymentHandlerInterface +{ + public function __construct(private string $handlerId) + { + } + + public function getHandlerId(): string + { + return $this->handlerId; + } + + public function exchangePaymentToken(array $credential): array + { + return []; + } + + public function authorize(Order $order, array $paymentData): PaymentOutcome + { + return PaymentOutcome::completed(); + } + + public function capture(Order $order, array $paymentData): PaymentOutcome + { + return PaymentOutcome::completed(); + } + + public function supports(Order $order): bool + { + return true; + } +} + +/** + * discovery テスト用の in-memory 非 UCP (base のみ) 決済ハンドラ. + */ +final readonly class InMemoryBasePaymentHandler implements AgentCheckoutPaymentHandlerInterface +{ + public function __construct(private string $handlerId) + { + } + + public function getHandlerId(): string + { + return $this->handlerId; + } + + public function authorize(Order $order, array $paymentData): PaymentOutcome + { + return PaymentOutcome::completed(); + } + + public function capture(Order $order, array $paymentData): PaymentOutcome + { + return PaymentOutcome::completed(); + } + + public function supports(Order $order): bool + { + return true; + } +}