diff --git a/app/config/eccube/services.yaml b/app/config/eccube/services.yaml index ae666313f51..2d3d6c41338 100644 --- a/app/config/eccube/services.yaml +++ b/app/config/eccube/services.yaml @@ -58,6 +58,10 @@ services: lazy: true public: true + Eccube\Service\EnvFileService: + arguments: + $projectDir: '%kernel.project_dir%' + Eccube\Service\Composer\ComposerProcessService: lazy: true diff --git a/src/Eccube/Controller/Admin/Setting/System/SecurityController.php b/src/Eccube/Controller/Admin/Setting/System/SecurityController.php index 9e34fdbd1bd..4caece0aace 100644 --- a/src/Eccube/Controller/Admin/Setting/System/SecurityController.php +++ b/src/Eccube/Controller/Admin/Setting/System/SecurityController.php @@ -15,6 +15,7 @@ use Eccube\Controller\AbstractController; use Eccube\Form\Type\Admin\SecurityType; +use Eccube\Service\EnvFileService; use Eccube\Util\CacheUtil; use Eccube\Util\StringUtil; use Symfony\Bridge\Twig\Attribute\Template; @@ -25,11 +26,27 @@ class SecurityController extends AbstractController { + /** + * この画面が .env へ書き込む環境変数キー(OS 環境変数によるオーバーライド判定に使用). + */ + private const ENV_KEYS = [ + 'ECCUBE_FRONT_ALLOW_HOSTS', + 'ECCUBE_FRONT_DENY_HOSTS', + 'ECCUBE_ADMIN_ALLOW_HOSTS', + 'ECCUBE_ADMIN_DENY_HOSTS', + 'ECCUBE_FORCE_SSL', + 'TRUSTED_HOSTS', + 'ECCUBE_ADMIN_ROUTE', + ]; + /** * SecurityController constructor. */ - public function __construct(protected TokenStorageInterface $tokenStorage, private readonly CacheUtil $cacheUtil) - { + public function __construct( + protected TokenStorageInterface $tokenStorage, + private readonly CacheUtil $cacheUtil, + private readonly EnvFileService $envFileService, + ) { } /** @@ -44,13 +61,20 @@ public function index(Request $request): RedirectResponse|array $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { - // .envファイルが存在しないときに設定は失敗する - if (file_exists($this->getParameter('kernel.project_dir').'/.env') === false) { + // .env ファイル自体が読み込まれない状況(全キー反映不可)では保存を拒否する + $ineffectiveReasons = $this->envFileService->getIneffectiveReasons(); + if ([] !== $ineffectiveReasons) { $this->addError('admin.common.save_error', 'admin'); + foreach ($ineffectiveReasons as $reason) { + $this->addError('admin.system.env.ineffective.'.$reason, 'admin'); + } return $this->redirectToRoute('admin_setting_system_security'); } + // OS 環境変数やカスケードファイルで上書きされているキーは書き込んでも反映されないため除外する + $overriddenKeys = $this->envFileService->getOverriddenKeys(self::ENV_KEYS); + $data = $form->getData(); $envFile = $this->getParameter('kernel.project_dir').'/.env'; $env = file_get_contents($envFile); @@ -69,20 +93,29 @@ public function index(Request $request): RedirectResponse|array array_filter(\explode("\n", StringUtil::convertLineFeed($data['admin_deny_hosts'])), fn ($str) => StringUtil::isNotBlank($str)) ); - $env = StringUtil::replaceOrAddEnv($env, [ + // 上書きされているキーは書き込み対象から除外する + $replace = array_diff_key([ 'ECCUBE_FRONT_ALLOW_HOSTS' => "'{$frontAllowHosts}'", 'ECCUBE_FRONT_DENY_HOSTS' => "'{$frontDenyHosts}'", 'ECCUBE_ADMIN_ALLOW_HOSTS' => "'{$adminAllowHosts}'", 'ECCUBE_ADMIN_DENY_HOSTS' => "'{$adminDenyHosts}'", 'ECCUBE_FORCE_SSL' => $data['force_ssl'] ? '1' : '0', 'TRUSTED_HOSTS' => $data['trusted_hosts'], - ]); + ], array_flip($overriddenKeys)); - file_put_contents($envFile, $env); + if ([] !== $replace) { + $env = StringUtil::replaceOrAddEnv($env, $replace); + file_put_contents($envFile, $env); + } + + // 上書きされ反映されなかったキーは名指しで警告する + if ([] !== $overriddenKeys) { + $this->addWarning(trans('admin.system.env.ineffective.overridden', ['%keys%' => implode(', ', $overriddenKeys)]), 'admin'); + } - // 管理画面URLの更新. 変更されている場合はログアウトし再ログインさせる. + // 管理画面URLの更新. 上書きされておらず変更されている場合はログアウトし再ログインさせる. $adminRoute = $this->eccubeConfig['eccube_admin_route']; - if ($adminRoute !== $data['admin_route_dir']) { + if ($adminRoute !== $data['admin_route_dir'] && !in_array('ECCUBE_ADMIN_ROUTE', $overriddenKeys, true)) { $env = StringUtil::replaceOrAddEnv($env, [ 'ECCUBE_ADMIN_ROUTE' => $data['admin_route_dir'], ]); @@ -115,13 +148,21 @@ public function index(Request $request): RedirectResponse|array $this->addWarning('admin.setting.system.security.admin_url_warning', 'admin'); } - // .envファイルが存在しない場合警告を出す。 - if (file_exists($this->getParameter('kernel.project_dir').'/.env') === false) { - $this->addWarning('admin.setting.system.security.not_found_env_file', 'admin'); + // .env ファイル自体が読み込まれない状況では警告を出し、登録ボタンを無効化する。 + $ineffectiveReasons = $this->envFileService->getIneffectiveReasons(); + foreach ($ineffectiveReasons as $reason) { + $this->addWarning('admin.system.env.ineffective.'.$reason, 'admin'); + } + + // 個々のキーが OS 環境変数等で上書きされている場合は該当キーを名指しで警告する。 + $overriddenKeys = $this->envFileService->getOverriddenKeys(self::ENV_KEYS); + if ([] !== $overriddenKeys) { + $this->addWarning(trans('admin.system.env.ineffective.overridden', ['%keys%' => implode(', ', $overriddenKeys)]), 'admin'); } return [ 'form' => $form->createView(), + 'envWritable' => [] === $ineffectiveReasons, ]; } } diff --git a/src/Eccube/Controller/Admin/Store/TemplateController.php b/src/Eccube/Controller/Admin/Store/TemplateController.php index cca6c9be39e..fc3a987eb08 100644 --- a/src/Eccube/Controller/Admin/Store/TemplateController.php +++ b/src/Eccube/Controller/Admin/Store/TemplateController.php @@ -18,6 +18,7 @@ use Eccube\Form\Type\Admin\TemplateType; use Eccube\Repository\Master\DeviceTypeRepository; use Eccube\Repository\TemplateRepository; +use Eccube\Service\EnvFileService; use Eccube\Util\CacheUtil; use Eccube\Util\StringUtil; use Symfony\Bridge\Twig\Attribute\Template; @@ -33,10 +34,15 @@ class TemplateController extends AbstractController { + /** + * この画面が .env へ書き込む環境変数キー(OS 環境変数によるオーバーライド判定に使用). + */ + private const ENV_KEYS = ['ECCUBE_TEMPLATE_CODE']; + /** * TemplateController constructor. */ - public function __construct(protected TemplateRepository $templateRepository, protected DeviceTypeRepository $deviceTypeRepository, private readonly CacheUtil $cacheUtil) + public function __construct(protected TemplateRepository $templateRepository, protected DeviceTypeRepository $deviceTypeRepository, private readonly CacheUtil $cacheUtil, private readonly EnvFileService $envFileService) { } @@ -59,10 +65,28 @@ public function index(Request $request): array|RedirectResponse $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { + // .env への書き込みが反映されない状況では保存を拒否する + // - .env が存在しない / 書き込み不可 + // - .env.local.php(dump-env の最適化済みスナップショット)が .env より優先される + // - ECCUBE_TEMPLATE_CODE が OS 環境変数やカスケードファイルで .env を上書きしている + $ineffectiveReasons = $this->envFileService->getIneffectiveReasons(); + $overriddenKeys = $this->envFileService->getOverriddenKeys(self::ENV_KEYS); + if ([] !== $ineffectiveReasons || [] !== $overriddenKeys) { + $this->addError('admin.common.save_error', 'admin'); + foreach ($ineffectiveReasons as $reason) { + $this->addError('admin.system.env.ineffective.'.$reason, 'admin'); + } + if ([] !== $overriddenKeys) { + $this->addError(trans('admin.system.env.ineffective.overridden', ['%keys%' => implode(', ', $overriddenKeys)]), 'admin'); + } + + return $this->redirectToRoute('admin_store_template'); + } + $Template = $this->templateRepository->find($form['selected']->getData()); $envFile = $this->getParameter('kernel.project_dir').'/.env'; - $env = file_exists($envFile) ? file_get_contents($envFile) : ''; + $env = file_get_contents($envFile); $env = StringUtil::replaceOrAddEnv($env, [ 'ECCUBE_TEMPLATE_CODE' => $Template->getCode(), @@ -72,25 +96,28 @@ public function index(Request $request): array|RedirectResponse $this->addSuccess('admin.common.save_complete', 'admin'); - // 次のいずれかの場合、.env への書き込みが起動時のロードで反映されない: - // 1. ECCUBE_TEMPLATE_CODE がプロセス環境変数として設定されている(Docker などで明示的に設定した場合)。 - // bootEnv は OS 環境変数を上書きしないため .env の値が使われない。 - // 2. .env.local.php(dump-env の最適化済みスナップショット)が存在する。 - // bootEnv は .env より .env.local.php を優先するため、再度 `composer symfony:dump-env` を - // 実行するまで .env の変更が反映されない。 - $envLocalPhp = $this->getParameter('kernel.project_dir').'/.env.local.php'; - if (false !== getenv('ECCUBE_TEMPLATE_CODE') || file_exists($envLocalPhp)) { - $this->addWarning('admin.store.template.env_override_warning', 'admin'); - } - $this->cacheUtil->clearCache(); return $this->redirectToRoute('admin_store_template'); } + // .env への書き込みが反映されない状況では警告を出し、登録ボタンを無効化する。 + $ineffectiveReasons = $this->envFileService->getIneffectiveReasons(); + foreach ($ineffectiveReasons as $reason) { + $this->addWarning('admin.system.env.ineffective.'.$reason, 'admin'); + } + + // 対象キー(ECCUBE_TEMPLATE_CODE)が上書きされている場合は名指しで警告する。 + $overriddenKeys = $this->envFileService->getOverriddenKeys(self::ENV_KEYS); + if ([] !== $overriddenKeys) { + $this->addWarning(trans('admin.system.env.ineffective.overridden', ['%keys%' => implode(', ', $overriddenKeys)]), 'admin'); + } + return [ 'form' => $form->createView(), 'Templates' => $Templates, + // この画面が扱うキーは 1 つのみのため, 上書きされていれば実質全滅として無効化する。 + 'envWritable' => [] === $ineffectiveReasons && [] === $overriddenKeys, ]; } @@ -98,7 +125,7 @@ public function index(Request $request): array|RedirectResponse * テンプレート一覧からのダウンロード */ #[Route(path: '/%eccube_admin_route%/store/template/{id}/download', name: 'admin_store_template_download', requirements: ['id' => '\d+'], methods: ['GET'])] - public function download(Request $request, \Eccube\Entity\Template $Template): BinaryFileResponse + public function download(\Eccube\Entity\Template $Template): BinaryFileResponse { // 該当テンプレートのディレクトリ $templateCode = $Template->getCode(); @@ -155,7 +182,7 @@ public function download(Request $request, \Eccube\Entity\Template $Template): B } #[Route(path: '/%eccube_admin_route%/store/template/{id}/delete', name: 'admin_store_template_delete', requirements: ['id' => '\d+'], methods: ['DELETE'])] - public function delete(Request $request, \Eccube\Entity\Template $Template): RedirectResponse + public function delete(\Eccube\Entity\Template $Template): RedirectResponse { $this->isTokenValid(); diff --git a/src/Eccube/Resource/locale/messages.en.yaml b/src/Eccube/Resource/locale/messages.en.yaml index 04ede3d0514..acd8d8cf27a 100644 --- a/src/Eccube/Resource/locale/messages.en.yaml +++ b/src/Eccube/Resource/locale/messages.en.yaml @@ -1428,7 +1428,10 @@ admin.setting.system.security.force_ssl_description: Only https access is allowe admin.setting.system.security.ip_limit_invalid_ipv4: "%ip% is not an IPv4 address." admin.setting.system.security.ip_limit_invalid_https: "http is not allowed to do this setting." admin.setting.system.security.admin_url_warning: Please set the Admin Console URL that is hard to guess for security. -admin.setting.system.security.not_found_env_file: .env file not found. If you do not use the .env file you can not change security config in Admin Console. +admin.system.env.ineffective.not_found: 'The .env file does not exist, so this setting cannot be changed from the admin screen. Please set the environment variables on the server directly.' +admin.system.env.ineffective.not_writable: 'The .env file is not writable, so this setting cannot be changed from the admin screen. Please check the write permission of the .env file.' +admin.system.env.ineffective.local_php: 'Because .env.local.php exists, changes to .env are not applied and cannot be made from the admin screen. Update .env on the server and re-run composer dump-env, or remove .env.local.php to re-enable changes from the admin screen.' +admin.system.env.ineffective.overridden: 'The environment variable(s) for this setting (%keys%) are overridden by OS process environment variables or a cascade file (.env.local, etc.), so changes to .env are not applied. Please change the environment variable on the server directly.' admin.setting.system.security.trusted_hosts: Trusted hosts admin.setting.system.security.trusted_hosts_sample: ^example\.com$,^example\.org$ admin.setting.system.security.trusted_hosts_description: | @@ -1515,7 +1518,6 @@ admin.store.template.delete_error__current_template: You are not allowed to dele admin.store.template.template_code_already_exists: This template code is already used. admin.store.template.upload_failed: Failed to upload admin.store.template.invalid_upload_file: Wrong extension of the file -admin.store.template.env_override_warning: "ECCUBE_TEMPLATE_CODE is set as an environment variable, so changes to .env will not take effect. To switch templates, update the ECCUBE_TEMPLATE_CODE environment variable." admin.store.plugin: Plugins admin.store.plugin.plugin_owners_install: Search Plugins admin.store.plugin.plugin_list: All Plugins diff --git a/src/Eccube/Resource/locale/messages.ja.yaml b/src/Eccube/Resource/locale/messages.ja.yaml index 3dbaeedf0bb..10445399ff9 100644 --- a/src/Eccube/Resource/locale/messages.ja.yaml +++ b/src/Eccube/Resource/locale/messages.ja.yaml @@ -1428,7 +1428,10 @@ admin.setting.system.security.ip_limit_invalid_ipv4: "%ip%はIPv4アドレスで admin.setting.system.security.ip_limit_invalid_ip_and_submask: "%ip%はIPv4/ビットマスクの形式ではありません。" admin.setting.system.security.ip_limit_invalid_https: "httpの場合には設定できません。" admin.setting.system.security.admin_url_warning: 管理画面URLは、セキュリティのため推測されにくいものを設定してください。 -admin.setting.system.security.not_found_env_file: .envファイルが見つかりません。.envを利用していない場合はセキュリティ設定を管理画面から変更できません。 +admin.system.env.ineffective.not_found: '.env ファイルが存在しないため、この設定は管理画面から変更できません。サーバーの環境変数を直接設定してください。' +admin.system.env.ineffective.not_writable: '.env ファイルに書き込み権限がないため、この設定は管理画面から変更できません。.env ファイルの書き込み権限を確認してください。' +admin.system.env.ineffective.local_php: '.env.local.php が存在するため、.env への変更は反映されず、管理画面からは変更できません。サーバー側で .env を更新してから composer dump-env を再実行するか、.env.local.php を削除して管理画面からの変更を再開してください。' +admin.system.env.ineffective.overridden: 'この設定の環境変数(%keys%)が OS のプロセス環境変数またはカスケードファイル(.env.local 等)で上書きされているため、.env への変更は反映されません。サーバーの環境変数を直接変更してください。' admin.setting.system.security.trusted_hosts: 信頼できるホスト名 admin.setting.system.security.trusted_hosts_sample: ^example\.com$,^example\.org$ admin.setting.system.security.trusted_hosts_description: | @@ -1515,7 +1518,6 @@ admin.store.template.delete_error__current_template: 設定中のテンプレー admin.store.template.template_code_already_exists: テンプレートコードは既に利用されています admin.store.template.upload_failed: アップロードに失敗しました admin.store.template.invalid_upload_file: ファイルの拡張子に誤りがあります -admin.store.template.env_override_warning: "ECCUBE_TEMPLATE_CODE が環境変数として設定されているため、.env への変更は反映されません。テンプレートを切り替えるには環境変数の ECCUBE_TEMPLATE_CODE を変更してください。" admin.store.plugin: プラグイン admin.store.plugin.plugin_owners_install: プラグインを探す admin.store.plugin.plugin_list: プラグイン一覧 diff --git a/src/Eccube/Resource/template/admin/Setting/System/security.twig b/src/Eccube/Resource/template/admin/Setting/System/security.twig index 5afaf986d69..4b78931e948 100644 --- a/src/Eccube/Resource/template/admin/Setting/System/security.twig +++ b/src/Eccube/Resource/template/admin/Setting/System/security.twig @@ -219,7 +219,7 @@ file that was distributed with this source code.
- +
diff --git a/src/Eccube/Resource/template/admin/Store/template.twig b/src/Eccube/Resource/template/admin/Store/template.twig index 7d3adf5541f..642e61e4006 100644 --- a/src/Eccube/Resource/template/admin/Store/template.twig +++ b/src/Eccube/Resource/template/admin/Store/template.twig @@ -120,7 +120,7 @@ file that was distributed with this source code.
- +
diff --git a/src/Eccube/Service/EnvFileService.php b/src/Eccube/Service/EnvFileService.php new file mode 100644 index 00000000000..f4642914c73 --- /dev/null +++ b/src/Eccube/Service/EnvFileService.php @@ -0,0 +1,173 @@ +projectDir.'/.env'; + if (!file_exists($envFile)) { + $reasons[] = self::REASON_NOT_FOUND; + } elseif (!is_writable($envFile)) { + $reasons[] = self::REASON_NOT_WRITABLE; + } + + // .env.local.php があると bootEnv は .env より優先するため, .env の変更は反映されない + if (file_exists($this->projectDir.'/.env.local.php')) { + $reasons[] = self::REASON_LOCAL_PHP; + } + + return $reasons; + } + + /** + * 対象キーのうち, .env への書き込みが実行時に反映されないキー(上書きされているキー)を返す. + * + * Symfony Dotenv は putenv を使わないため getenv だけでは判定できない. + * .env 系ファイルから実際に populate されたキーは $_SERVER['SYMFONY_DOTENV_VARS'] に載るので, + * そこに含まれ, かつカスケードファイルで再定義されていないキーは「.env が実効」=反映されると判定する. + * それ以外で $_ENV / $_SERVER / getenv に値があるキーは OS 環境変数側が勝つため反映されない. + * + * @param string[] $keys 対象の環境変数キー + * + * @return string[] 上書きされているキーの部分集合 + */ + public function getOverriddenKeys(array $keys): array + { + if ([] === $keys) { + return []; + } + + // Dotenv が実際に .env 系ファイルから populate したキーの一覧. + // これらは .env(系)の値が実効値なので, .env への書き込みは反映される. + $dotenvVars = array_flip(array_filter(explode(',', + (string) ($_SERVER['SYMFONY_DOTENV_VARS'] ?? $_ENV['SYMFONY_DOTENV_VARS'] ?? '') + ))); + + // .env より後に読まれ .env の値を上書きするカスケードファイルで定義されたキー. + $cascadeKeys = $this->getCascadeDefinedKeys(); + + $overridden = []; + foreach ($keys as $key) { + // .env 系から populate されており, カスケードで再定義されていなければ反映される. + if (isset($dotenvVars[$key]) && !isset($cascadeKeys[$key])) { + continue; + } + // カスケードで再定義されている, もしくは Dotenv が触れていない + // プロセス環境変数($_ENV / $_SERVER / getenv)が存在する場合は反映されない. + if (isset($cascadeKeys[$key]) + || isset($_ENV[$key]) || isset($_SERVER[$key]) || false !== getenv($key)) { + $overridden[] = $key; + } + } + + return $overridden; + } + + /** + * 対象の環境変数について, .env への書き込みが有効に反映されるか. + * + * @param string[] $keys 対象の環境変数キー + */ + public function isEffective(array $keys = []): bool + { + return [] === $this->getIneffectiveReasons() && [] === $this->getOverriddenKeys($keys); + } + + /** + * .env の後に読まれるカスケードファイルで定義されているキーの一覧を返す. + * + * Dotenv::loadEnv は .env → .env.local → .env.$env → .env.$env.local の順に読み, + * 後から読んだファイルが .env の値を上書きする. ファイルの存在だけでは過剰なため, + * 実際にキーを定義しているファイルのみを対象にする. + * + * @return array キー名 => true + */ + private function getCascadeDefinedKeys(): array + { + $appEnv = $_SERVER['APP_ENV'] ?? $_ENV['APP_ENV'] ?? null; + $cascadeFiles = array_filter([ + '.env.local', + $appEnv ? '.env.'.$appEnv : null, + $appEnv ? '.env.'.$appEnv.'.local' : null, + ]); + + $defined = []; + foreach ($cascadeFiles as $file) { + $path = $this->projectDir.'/'.$file; + if (!is_file($path) || !is_readable($path)) { + continue; + } + + try { + $parsed = (new Dotenv())->parse((string) file_get_contents($path), $path); + } catch (FormatException) { + // 解析できないファイルは判定対象から除外する(管理画面を落とさない) + continue; + } + + foreach (array_keys($parsed) as $name) { + $defined[$name] = true; + } + } + + return $defined; + } +} diff --git a/tests/Eccube/Tests/EnvOverrideTrait.php b/tests/Eccube/Tests/EnvOverrideTrait.php new file mode 100644 index 00000000000..67c248713ac --- /dev/null +++ b/tests/Eccube/Tests/EnvOverrideTrait.php @@ -0,0 +1,76 @@ + を指し, Twig の FilesystemLoader::addPath が + * LoaderError を投げて全リクエストが 500 になる(実効値なら環境差なく成立する). + * + * @param string $key 対象の環境変数キー + * @param string $value 注入する値. アプリケーションの実効値と同じ値を渡す + * @param callable $fn 上書き状態で実行する処理 + */ + protected function forceKeyOverridden(string $key, string $value, callable $fn): void + { + $sentinel = '__ECCUBE_TEST_UNSET__'; + $origVars = \array_key_exists('SYMFONY_DOTENV_VARS', $_SERVER) ? $_SERVER['SYMFONY_DOTENV_VARS'] : $sentinel; + $origVal = \array_key_exists($key, $_SERVER) ? $_SERVER[$key] : $sentinel; + + $vars = array_filter( + explode(',', (string) ($sentinel === $origVars ? '' : $origVars)), + fn ($k) => $k !== $key && '' !== $k + ); + $_SERVER['SYMFONY_DOTENV_VARS'] = implode(',', $vars); + $_SERVER[$key] = $value; + + try { + $fn(); + } finally { + if ($sentinel === $origVars) { + unset($_SERVER['SYMFONY_DOTENV_VARS']); + } else { + $_SERVER['SYMFONY_DOTENV_VARS'] = $origVars; + } + if ($sentinel === $origVal) { + unset($_SERVER[$key]); + } else { + $_SERVER[$key] = $origVal; + } + } + } +} diff --git a/tests/Eccube/Tests/Service/EnvFileServiceTest.php b/tests/Eccube/Tests/Service/EnvFileServiceTest.php new file mode 100644 index 00000000000..3c4286f0d1b --- /dev/null +++ b/tests/Eccube/Tests/Service/EnvFileServiceTest.php @@ -0,0 +1,217 @@ +fs = new Filesystem(); + $this->projectDir = sys_get_temp_dir().'/eccube_env_test_'.uniqid(); + $this->fs->mkdir($this->projectDir); + } + + protected function tearDown(): void + { + $this->fs->remove($this->projectDir); + parent::tearDown(); + } + + public function testEffectiveWhenEnvExistsAndWritable(): void + { + $this->fs->dumpFile($this->projectDir.'/.env', "FOO=bar\n"); + + $service = new EnvFileService($this->projectDir); + + $this->assertSame([], $service->getIneffectiveReasons()); + $this->assertTrue($service->isEffective(['FOO'])); + } + + public function testNotFoundWhenEnvIsAbsent(): void + { + $service = new EnvFileService($this->projectDir); + + $this->assertContains(EnvFileService::REASON_NOT_FOUND, $service->getIneffectiveReasons()); + $this->assertFalse($service->isEffective()); + } + + public function testNotWritableWhenEnvIsReadOnly(): void + { + $envFile = $this->projectDir.'/.env'; + $this->fs->dumpFile($envFile, "FOO=bar\n"); + $this->fs->chmod($envFile, 0o444); + + $service = new EnvFileService($this->projectDir); + + // root で実行される環境では書き込み権限判定が効かないためスキップ + if (is_writable($envFile)) { + $this->markTestSkipped('.env is writable even with 0444 (running as root?)'); + } + + $this->assertContains(EnvFileService::REASON_NOT_WRITABLE, $service->getIneffectiveReasons()); + $this->assertFalse($service->isEffective()); + } + + public function testLocalPhpSnapshotMakesEnvIneffective(): void + { + $this->fs->dumpFile($this->projectDir.'/.env', "FOO=bar\n"); + $this->fs->dumpFile($this->projectDir.'/.env.local.php', "projectDir); + + $this->assertContains(EnvFileService::REASON_LOCAL_PHP, $service->getIneffectiveReasons()); + $this->assertFalse($service->isEffective()); + } + + /** + * .env 系ファイルから Dotenv が populate したキー(SYMFONY_DOTENV_VARS に載る)は, + * $_SERVER / getenv に同名の値があっても「.env が実効」として上書き扱いしないこと. + * + * index.php:34 の boot_env(.., true) 経路(APP_ENV 未設定=非 Docker)では .env が + * OS 環境変数に勝つ. この経路を getenv だけで判定すると誤検知し, 反映される保存を + * ブロックしてしまう回帰を防ぐ(#6130 の核心). + */ + public function testNotOverriddenWhenKeyIsPopulatedFromDotenv(): void + { + $this->fs->dumpFile($this->projectDir.'/.env', "ECCUBE_TEST_KEY_A=fromdotenv\n"); + $service = new EnvFileService($this->projectDir); + $key = 'ECCUBE_TEST_KEY_A'; + + $this->withGlobals([ + 'server' => ['SYMFONY_DOTENV_VARS' => $key, $key => 'fromdotenv'], + ], function () use ($service, $key): void { + $this->assertSame([], $service->getOverriddenKeys([$key])); + $this->assertTrue($service->isEffective([$key])); + }); + } + + /** + * Dotenv が触れていない(SYMFONY_DOTENV_VARS 非掲載)のに $_SERVER 側に値がある + * キー(nginx fastcgi_param / Apache SetEnv 相当)は上書きと判定すること. + */ + public function testOverriddenWhenKeyOnlyInServer(): void + { + $this->fs->dumpFile($this->projectDir.'/.env', "FOO=bar\n"); + $service = new EnvFileService($this->projectDir); + $key = 'ECCUBE_TEST_KEY_B'; + + $this->withGlobals([ + 'server' => ['SYMFONY_DOTENV_VARS' => '', $key => 'osvalue'], + ], function () use ($service, $key): void { + $this->assertSame([$key], $service->getOverriddenKeys([$key])); + $this->assertFalse($service->isEffective([$key])); + }); + } + + /** + * .env.local / .env.$APP_ENV 等のカスケードファイルで対象キーが再定義されている場合, + * .env の値は上書きされるため上書きと判定すること. 対象キーを含まないファイルは無視すること. + */ + public function testOverriddenWhenKeyRedefinedInCascadeFile(): void + { + $this->fs->dumpFile($this->projectDir.'/.env', "ECCUBE_TEST_KEY_A=fromdotenv\nECCUBE_TEST_KEY_B=fromdotenv\n"); + // .env.local は KEY_A のみ再定義する + $this->fs->dumpFile($this->projectDir.'/.env.local', "ECCUBE_TEST_KEY_A=fromlocal\n"); + $service = new EnvFileService($this->projectDir); + + $this->withGlobals([ + 'server' => ['SYMFONY_DOTENV_VARS' => 'ECCUBE_TEST_KEY_A,ECCUBE_TEST_KEY_B'], + ], function () use ($service): void { + // KEY_A はカスケードで上書きされるため反映されない + $this->assertSame(['ECCUBE_TEST_KEY_A'], $service->getOverriddenKeys(['ECCUBE_TEST_KEY_A'])); + // KEY_B はカスケードに無く .env 由来のため反映される + $this->assertSame([], $service->getOverriddenKeys(['ECCUBE_TEST_KEY_B'])); + }); + } + + /** + * 複数キーのうち上書きされているキーだけを部分集合として返すこと(画面全体を止めないための土台). + */ + public function testGetOverriddenKeysReturnsOnlyOverriddenSubset(): void + { + $this->fs->dumpFile($this->projectDir.'/.env', "ECCUBE_TEST_KEY_A=fromdotenv\nECCUBE_TEST_KEY_B=fromdotenv\n"); + $service = new EnvFileService($this->projectDir); + + $this->withGlobals([ + // KEY_A/KEY_B は .env 由来。うち KEY_A だけ OS 環境変数が上書き(SYMFONY_DOTENV_VARS には未掲載) + 'server' => ['SYMFONY_DOTENV_VARS' => 'ECCUBE_TEST_KEY_B', 'ECCUBE_TEST_KEY_A' => 'osvalue'], + ], function () use ($service): void { + $this->assertSame( + ['ECCUBE_TEST_KEY_A'], + $service->getOverriddenKeys(['ECCUBE_TEST_KEY_A', 'ECCUBE_TEST_KEY_B']) + ); + }); + } + + public function testGetOverriddenKeysReturnsEmptyForNoKeys(): void + { + $this->fs->dumpFile($this->projectDir.'/.env', "FOO=bar\n"); + $service = new EnvFileService($this->projectDir); + + $this->assertSame([], $service->getOverriddenKeys([])); + } + + /** + * $_SERVER / $_ENV の指定キーを一時的に差し替え, コールバック実行後に元へ復元する. + * + * @param array{server?: array, env?: array} $overrides + */ + private function withGlobals(array $overrides, callable $fn): void + { + $sentinel = '__ECCUBE_TEST_UNSET__'; + $backup = ['server' => [], 'env' => []]; + + foreach (($overrides['server'] ?? []) as $key => $value) { + $backup['server'][$key] = \array_key_exists($key, $_SERVER) ? $_SERVER[$key] : $sentinel; + $_SERVER[$key] = $value; + } + foreach (($overrides['env'] ?? []) as $key => $value) { + $backup['env'][$key] = \array_key_exists($key, $_ENV) ? $_ENV[$key] : $sentinel; + $_ENV[$key] = $value; + } + + try { + $fn(); + } finally { + foreach ($backup['server'] as $key => $value) { + if ($sentinel === $value) { + unset($_SERVER[$key]); + } else { + $_SERVER[$key] = $value; + } + } + foreach ($backup['env'] as $key => $value) { + if ($sentinel === $value) { + unset($_ENV[$key]); + } else { + $_ENV[$key] = $value; + } + } + } + } +} diff --git a/tests/Eccube/Tests/Web/Admin/Setting/System/SecurityControllerTest.php b/tests/Eccube/Tests/Web/Admin/Setting/System/SecurityControllerTest.php index 0a543075470..be39e71814d 100644 --- a/tests/Eccube/Tests/Web/Admin/Setting/System/SecurityControllerTest.php +++ b/tests/Eccube/Tests/Web/Admin/Setting/System/SecurityControllerTest.php @@ -15,6 +15,7 @@ namespace Eccube\Tests\Web\Admin\Setting\System; +use Eccube\Tests\EnvOverrideTrait; use Eccube\Tests\Web\Admin\AbstractAdminWebTestCase; use PHPUnit\Framework\Attributes\Group; use Symfony\Component\HttpFoundation\Request; @@ -22,6 +23,8 @@ #[Group('cache-clear')] final class SecurityControllerTest extends AbstractAdminWebTestCase { + use EnvOverrideTrait; + protected $envFile; protected $env; @@ -55,7 +58,6 @@ public function testRouting() /** * Submit test */ - #[Group(name: 'cache-clear')] public function testSubmit() { $session = $this->createSession($this->client); @@ -80,6 +82,67 @@ public function testSubmit() $this->assertMatchesRegularExpression('/ECCUBE_ADMIN_ROUTE='.$formData['admin_route_dir'].'/', file_get_contents($this->envFile)); } + /** + * 反映可能な環境(.env が書き込み可能)では登録ボタンが無効化されないことを確認する(対照)。 + */ + public function testDisplayButtonEnabledWhenEffective(): void + { + $crawler = $this->client->request(Request::METHOD_GET, $this->generateUrl('admin_setting_system_security')); + $this->assertTrue($this->client->getResponse()->isSuccessful()); + $this->assertCount(0, $crawler->filter('button[type="submit"][disabled]')); + } + + /** + * 対象キーの 1 つ(ECCUBE_ADMIN_ROUTE)が OS 環境変数で上書きされていても、 + * 画面全体はブロックせず(登録ボタンは有効のまま)、該当キーを名指しで警告することを確認する(#6130 / #2)。 + */ + public function testDisplayWarnsNamedKeyButKeepsButtonEnabledWhenSingleKeyOverridden(): void + { + $this->forceKeyOverridden('ECCUBE_ADMIN_ROUTE', $this->currentAdminRoute(), function (): void { + $crawler = $this->client->request(Request::METHOD_GET, $this->generateUrl('admin_setting_system_security')); + $this->assertTrue($this->client->getResponse()->isSuccessful()); + + // 残りのキーは .env に書けるため登録ボタンは無効化されない + $this->assertCount(0, $crawler->filter('button[type="submit"][disabled]')); + + // 上書きされているキーを名指しした警告が表示される + $this->assertStringContainsString( + trans('admin.system.env.ineffective.overridden', ['%keys%' => 'ECCUBE_ADMIN_ROUTE']), + (string) $this->client->getResponse()->getContent() + ); + }); + } + + /** + * 対象キーの 1 つ(ECCUBE_ADMIN_ROUTE)が上書きされている場合、そのキーは .env に書かず、 + * 反映される他キー(TRUSTED_HOSTS 等)は保存されることを確認する(#6130 / #2 の退行解消)。 + */ + public function testSubmitSkipsOverriddenKeyButSavesOthers(): void + { + $session = $this->createSession($this->client); + $formData = $this->createFormData(); + + $this->forceKeyOverridden('ECCUBE_ADMIN_ROUTE', $this->currentAdminRoute(), function () use ($session, $formData): void { + $this->client->request( + Request::METHOD_POST, + $this->generateUrl('admin_setting_system_security'), + ['admin_security' => $formData] + ); + + $this->assertTrue($this->client->getResponse()->isRedirection()); + + // 保存自体は拒否されない(save_error は出ない) + $errors = $session->getFlashBag()->get('eccube.admin.error'); + $this->assertNotContains('admin.common.save_error', $errors); + + $content = file_get_contents($this->envFile); + // 上書きされている ECCUBE_ADMIN_ROUTE は書き換わらない + $this->assertDoesNotMatchRegularExpression('/ECCUBE_ADMIN_ROUTE='.$formData['admin_route_dir'].'/', $content); + // 反映される TRUSTED_HOSTS は保存される + $this->assertStringContainsString('TRUSTED_HOSTS='.$formData['trusted_hosts'], (string) $content); + }); + } + /** * Submit when empty */ @@ -107,6 +170,18 @@ public function testSubmitEmpty() $this->assertSame($this->env, $newEnv); } + /** + * 現在アプリケーションが使用している管理画面のルーティングプレフィックス. + * + * ECCUBE_ADMIN_ROUTE の上書きを再現する際、実効値と異なる値を注入すると + * 管理画面の URL 自体が変わってしまうため、実効値をそのまま注入する + * ({@see EnvOverrideTrait::forceKeyOverridden()}). + */ + private function currentAdminRoute(): string + { + return (string) static::getContainer()->getParameter('eccube_admin_route'); + } + /** * Submit form */ diff --git a/tests/Eccube/Tests/Web/Admin/Store/TemplateControllerTest.php b/tests/Eccube/Tests/Web/Admin/Store/TemplateControllerTest.php index f2dae84578f..dfb00fb45d1 100644 --- a/tests/Eccube/Tests/Web/Admin/Store/TemplateControllerTest.php +++ b/tests/Eccube/Tests/Web/Admin/Store/TemplateControllerTest.php @@ -19,6 +19,7 @@ use Eccube\Entity\Template; use Eccube\Repository\Master\DeviceTypeRepository; use Eccube\Repository\TemplateRepository; +use Eccube\Tests\EnvOverrideTrait; use Eccube\Tests\Web\Admin\AbstractAdminWebTestCase; use Eccube\Util\StringUtil; use PHPUnit\Framework\Attributes\Group; @@ -29,6 +30,8 @@ final class TemplateControllerTest extends AbstractAdminWebTestCase { + use EnvOverrideTrait; + protected ?string $dir = null; protected ?UploadedFile $file = null; @@ -89,6 +92,28 @@ public function testDisplayList() $this->assertTrue($this->client->getResponse()->isSuccessful()); } + /** + * ECCUBE_TEMPLATE_CODE がプロセス環境変数として設定されている場合、画面表示時に + * 警告を表示し、登録ボタンを無効化することを確認する(#6130)。 + */ + #[Group(name: 'cache-clear')] + public function testDisplayWarningAndDisableButtonWhenEnvOverridden() + { + $this->forceKeyOverridden('ECCUBE_TEMPLATE_CODE', $this->currentThemeCode(), function (): void { + $crawler = $this->client->request(Request::METHOD_GET, $this->generateUrl('admin_store_template')); + $this->assertTrue($this->client->getResponse()->isSuccessful()); + + // この画面が扱うキーは1つのみのため, 上書き時は登録ボタンが無効化されている + $this->assertGreaterThan(0, $crawler->filter('button[type="submit"][disabled]')->count()); + + // 上書きされているキーを名指しした警告が表示されている + $this->assertStringContainsString( + trans('admin.system.env.ineffective.overridden', ['%keys%' => 'ECCUBE_TEMPLATE_CODE']), + (string) $this->client->getResponse()->getContent() + ); + }); + } + /** * テンプレートの変更 */ @@ -119,10 +144,9 @@ public function testChangeTemplate() * テンプレートの変更(ECCUBE_TEMPLATE_CODEがプロセス環境変数として設定されている場合) * * Docker などでプロセス環境変数として ECCUBE_TEMPLATE_CODE が設定されている場合、 - * .env への書き込みは反映されないため警告が表示されることを確認する。 - * - * @group cache-clear + * .env への書き込みは反映されないため、保存を拒否しエラーを表示することを確認する。 */ + #[Group(name: 'cache-clear')] public function testChangeTemplateWithEnvOverride() { // テンプレートをアップロード @@ -130,11 +154,9 @@ public function testChangeTemplateWithEnvOverride() $this->verifyUpload(); $Template = $this->templateRepository->findOneBy(['code' => $this->code]); + $this->assertInstanceOf(Template::class, $Template); - // プロセス環境変数として ECCUBE_TEMPLATE_CODE を設定 - putenv('ECCUBE_TEMPLATE_CODE=default'); - - try { + $this->forceKeyOverridden('ECCUBE_TEMPLATE_CODE', $this->currentThemeCode(), function () use ($Template): void { $session = $this->createSession($this->client); // テンプレートを選択 @@ -146,13 +168,18 @@ public function testChangeTemplateWithEnvOverride() ]); $this->assertTrue($this->client->getResponse()->isRedirection()); - // 警告メッセージが表示されている - $warnings = $session->getFlashBag()->get('eccube.admin.warning'); - $this->assertContains('admin.store.template.env_override_warning', $warnings); - } finally { - // プロセス環境変数を元に戻す - putenv('ECCUBE_TEMPLATE_CODE'); - } + // 環境変数オーバーライド時は保存が拒否され、エラーが表示される + $errors = $session->getFlashBag()->get('eccube.admin.error'); + $this->assertContains('admin.common.save_error', $errors); + // 原因となる上書きキー名も併記される(#8) + $this->assertContains( + trans('admin.system.env.ineffective.overridden', ['%keys%' => 'ECCUBE_TEMPLATE_CODE']), + $errors + ); + + // .env は書き換えられていない + $this->assertDoesNotMatchRegularExpression('/ECCUBE_TEMPLATE_CODE='.$Template->getCode().'/', file_get_contents($this->envFile)); + }); } /** @@ -268,6 +295,18 @@ public function testDelete() $this->assertFileDoesNotExist(static::getContainer()->getParameter('kernel.project_dir').'/app/template/'.$code); } + /** + * 現在アプリケーションが使用しているテンプレートコード. + * + * ECCUBE_TEMPLATE_CODE の上書きを再現する際、実効値と異なる値を注入すると + * twig.paths が存在しない app/template/ を指し全リクエストが 500 になるため、 + * 実効値をそのまま注入する({@see EnvOverrideTrait::forceKeyOverridden()}). + */ + private function currentThemeCode(): string + { + return (string) static::getContainer()->getParameter('eccube.theme'); + } + protected function scenarioUpload($uppercase = false) { $formData = $this->createFormData();