From 4d6565b1f58857944fbb6039ed1f1d3573fef5d6 Mon Sep 17 00:00:00 2001 From: August Miller Date: Tue, 5 May 2026 10:37:43 -0700 Subject: [PATCH 01/14] Remove stray docblock --- src/Config/GeneralConfig.php | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/Config/GeneralConfig.php b/src/Config/GeneralConfig.php index 7e5d96b2f29..506a06a2de7 100644 --- a/src/Config/GeneralConfig.php +++ b/src/Config/GeneralConfig.php @@ -6081,10 +6081,8 @@ public function tempAssetUploadFs(?string $value): self } /** - * Configures Craft to send all system emails to either a single email address or an array of email addresses - * for testing purposes. - * - * The timezone of the site. If set, it will take precedence over the Timezone setting in Settings → General. + * The timezone of the site. If set, it will take precedence over the Timezone setting in Settings → General + * (stored in project config). * * This can be set to one of PHP’s [supported timezones](https://php.net/manual/en/timezones.php). * From eabb412846b75920fc465e79b6e0b86450523f72 Mon Sep 17 00:00:00 2001 From: August Miller Date: Tue, 5 May 2026 10:40:38 -0700 Subject: [PATCH 02/14] Parameterize system timezone in install migration --- src/Database/Migrations/Install.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Database/Migrations/Install.php b/src/Database/Migrations/Install.php index c7b2ba70e7a..50d124f8f52 100644 --- a/src/Database/Migrations/Install.php +++ b/src/Database/Migrations/Install.php @@ -53,6 +53,7 @@ public function __construct( public ?string $password = null, public ?string $email = null, public ?Site $site = null, + public ?string $timezone = null, public bool $applyProjectConfigYaml = true, ) { parent::__construct(); @@ -1415,7 +1416,7 @@ private function _generateInitialConfig(): array 'name' => $this->site->getName(), 'live' => true, 'schemaVersion' => Cms::SCHEMA_VERSION, - 'timeZone' => 'America/Los_Angeles', + 'timeZone' => $this->timezone ?? 'America/Los_Angeles', ], 'users' => [ 'requireEmailVerification' => true, From 737f6035acadebb5bfef67e89e195885382cc302 Mon Sep 17 00:00:00 2001 From: August Miller Date: Tue, 5 May 2026 11:07:05 -0700 Subject: [PATCH 03/14] =?UTF-8?q?System=20timezone=20is=20configurable=20f?= =?UTF-8?q?rom=20install=20command=20-=20The=20installer=20may=20ask=20for?= =?UTF-8?q?=20a=20timezone=20even=20when=20it's=20present=20in=20project?= =?UTF-8?q?=20config.=20-=20Why=20can=20I=20not=20type=20`suggest()`?= =?UTF-8?q?=E2=80=99s=20`info`=20callback=20as=20`string`=3F=20The=20initi?= =?UTF-8?q?al=20"highlighted"=20value=20seems=20to=20be=20`null`=20which?= =?UTF-8?q?=20upsets=20it.=20-=20The=20env=20var=20detection=20is=20pretty?= =?UTF-8?q?=20crude.=20We=20could=20just=20parse=20the=20value=20indiscrim?= =?UTF-8?q?inately=3F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Commands/Install/InstallCommand.php | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/Console/Commands/Install/InstallCommand.php b/src/Console/Commands/Install/InstallCommand.php index 5d3e3324e61..8cce7089fbd 100644 --- a/src/Console/Commands/Install/InstallCommand.php +++ b/src/Console/Commands/Install/InstallCommand.php @@ -7,14 +7,17 @@ use CraftCms\Cms\Cms; use CraftCms\Cms\Config\GeneralConfig; use CraftCms\Cms\Console\CraftCommand; +use CraftCms\Cms\Cp\SelectOptions; use CraftCms\Cms\Database\Migrations\Install; use CraftCms\Cms\Database\Migrator; use CraftCms\Cms\Site\Concerns\SiteDefaults; use CraftCms\Cms\Site\Data\Site; use CraftCms\Cms\Support\Env; use CraftCms\Cms\Translation\I18N; +use CraftCms\Cms\Validation\Rules\EnvValueRule; use Illuminate\Console\Command; use Illuminate\Support\Facades\DB; +use Illuminate\Validation\Rule; use Illuminate\Validation\Rules\Password; use Laravel\Prompts\Support\Logger; use Override; @@ -42,6 +45,7 @@ class InstallCommand extends Command {--siteName= : The default site name for the first site to create during install.} {--siteUrl= : The default site URL for the first site to create during install.} {--language= : The default language for the first site to create during install.} + {--timezone= : The app’s default timezone, typically configured in Settings → General.} '; #[Override] @@ -138,6 +142,23 @@ public function handle( return null; } ), 'language') + ->addIf(! $this->option('timezone'), function () { + $timezoneBaseOptions = array_column(SelectOptions::getTimeZoneOptions(), 'value'); + $timezoneEnvOptions = array_column(SelectOptions::getEnvOptions($timezoneBaseOptions)[0]['options'] ?? [], 'value'); + + return suggest( + label: 'What timezone should the application use?', + options: [ + ...$timezoneBaseOptions, + ...$timezoneEnvOptions, + ], + default: date_default_timezone_get(), + required: true, + validate: [new EnvValueRule([Rule::in($timezoneBaseOptions)])], + hint: 'Type $ for environment variables containing valid timezones.', + info: Env::parse(...), + ); + }, 'timezone') ->submit(); $username = $this->option('username') ?? $responses['username']; @@ -146,6 +167,7 @@ public function handle( $siteName = $this->option('siteName') ?? $responses['siteName']; $siteUrl = $this->option('siteUrl') ?? $responses['siteUrl']; $language = $this->option('language') ?? $responses['language']; + $timezone = $this->option('timezone') ?? $responses['timezone']; if ($generalConfig->useEmailAsUsername) { $username = $email; @@ -183,6 +205,7 @@ public function handle( password: $password, email: $email, site: $site, + timezone: $timezone, ), 'up'); $migrator->getRepository()->log('Install', 1); From 39fdf24e1d3da2e1a940ed2bdbb98674250c5444 Mon Sep 17 00:00:00 2001 From: August Miller Date: Tue, 5 May 2026 11:07:50 -0700 Subject: [PATCH 04/14] Use `EnvValueRule` wrapper for general settings validation --- .../Settings/GeneralSettingsController.php | 37 +++++-------------- 1 file changed, 10 insertions(+), 27 deletions(-) diff --git a/src/Http/Controllers/Settings/GeneralSettingsController.php b/src/Http/Controllers/Settings/GeneralSettingsController.php index 7ce97d61891..c2ca59ee457 100644 --- a/src/Http/Controllers/Settings/GeneralSettingsController.php +++ b/src/Http/Controllers/Settings/GeneralSettingsController.php @@ -10,6 +10,7 @@ use CraftCms\Cms\ProjectConfig\ProjectConfig; use CraftCms\Cms\Support\Env; use CraftCms\Cms\Support\Url; +use CraftCms\Cms\Validation\Rules\EnvValueRule; use CraftCms\Cms\Validation\Rules\TimezoneRule; use Illuminate\Contracts\View\View; use Illuminate\Http\RedirectResponse; @@ -49,36 +50,18 @@ public function index(GeneralConfig $generalConfig): Response|View public function store(Request $request): RedirectResponse { - $resolvedValues = []; - - $envAllowedKeys = ['name', 'live', 'timeZone']; - foreach ($request->all() as $key => $value) { - if (in_array($key, $envAllowedKeys) && is_string($value) && str_starts_with($value, '$')) { - $resolvedValues[$key] = Env::parse($value); - } else { - $resolvedValues[$key] = $value; - } - } - - /** - * We want to validate against the resolved values, but we'll store what the user provided - */ - Validator::make($resolvedValues, [ - 'name' => ['required', 'string'], - 'live' => ['required', 'boolean'], + $settings = $request->validate([ + 'name' => [new EnvValueRule(['required', 'string'])], + 'live' => [new EnvValueRule(['required', 'boolean'])], 'retryDuration' => ['nullable', 'integer'], - 'timeZone' => ['required', 'string', new TimezoneRule], - ])->validate(); + 'timeZone' => [new EnvValueRule(['required', 'string', new TimezoneRule])], + ]); $systemSettings = $this->projectConfig->get('system') ?? []; - $systemSettings['name'] = $request->input('name'); - $systemSettings['live'] = $request->input('live'); - $systemSettings['retryDuration'] = $request->input('retryDuration') ?: null; - $systemSettings['timeZone'] = $request->input('timeZone'); - - if (! str_starts_with((string) $systemSettings['live'], '$')) { - $systemSettings['live'] = $request->boolean('live'); - } + $systemSettings['name'] = $settings['name']; + $systemSettings['live'] = $settings['live']; + $systemSettings['retryDuration'] = $settings['retryDuration'] ?: null; + $systemSettings['timeZone'] = $settings['timeZone']; $this->projectConfig->set('system', $systemSettings, 'Update system settings.'); From 5798cbe657d2b40a6ba8587e42872ecb0b8d9c7f Mon Sep 17 00:00:00 2001 From: August Miller Date: Tue, 5 May 2026 11:34:44 -0700 Subject: [PATCH 05/14] Add timezones to installer response --- src/Http/Controllers/InstallController.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Http/Controllers/InstallController.php b/src/Http/Controllers/InstallController.php index 30642aae7ea..914aa3b7483 100644 --- a/src/Http/Controllers/InstallController.php +++ b/src/Http/Controllers/InstallController.php @@ -89,6 +89,11 @@ public function index(GeneralConfig $generalConfig): \Inertia\Response 'postCpLoginRedirect' => $postCpLoginRedirect, 'licenseHtml' => Inertia::defer(fn () => $licenseHtml), 'localeOptions' => Inertia::defer(fn () => $localeOptions), + 'timezone' => Inertia::defer(function () { + $timezoneOptions = SelectOptions::getTimeZoneOptions(); + + return array_merge($timezoneOptions, SelectOptions::getEnvOptions(array_column($timezoneOptions, 'value'))); + }), 'baseUrlSuggestions' => SelectOptions::getEnvSuggestions(true, fn ($value) => Str::isUrl($value)), 'defaultSystemName' => $defaultSystemName, 'defaultSiteUrl' => $defaultSiteUrl, From f7c1f415f18a75ca801059e0ff7df45db342f33a Mon Sep 17 00:00:00 2001 From: August Miller Date: Tue, 5 May 2026 11:44:04 -0700 Subject: [PATCH 06/14] Use `EnvValueRule` to validate site settings in install controller --- src/Http/Controllers/InstallController.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Http/Controllers/InstallController.php b/src/Http/Controllers/InstallController.php index 914aa3b7483..a80222e68c2 100644 --- a/src/Http/Controllers/InstallController.php +++ b/src/Http/Controllers/InstallController.php @@ -17,7 +17,9 @@ use CraftCms\Cms\Support\Facades\I18N; use CraftCms\Cms\Support\Str; use CraftCms\Cms\Support\Url; +use CraftCms\Cms\Validation\Rules\EnvValueRule; use CraftCms\Cms\Validation\Rules\LanguageRule; +use CraftCms\Cms\Validation\Rules\TimezoneRule; use Illuminate\Database\SQLiteDatabaseDoesNotExistException; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -149,14 +151,11 @@ public function validateSite(Request $request): Response { $request->validate([ 'name' => ['required', 'string', 'max:255'], - 'baseUrl' => ['nullable', 'string', 'max:255'], + 'baseUrl' => [new EnvValueRule(['nullable', 'string', 'max:255'])], 'language' => ['required', 'string', 'max:255', new LanguageRule(onlySiteLanguages: false)], + 'timezone' => [new EnvValueRule([new TimezoneRule])], ]); - $baseUrl = Env::parse($request->input('baseUrl')); - - Validator::validate(compact('baseUrl'), ['baseUrl' => 'url']); - return new JsonResponse; } @@ -226,6 +225,7 @@ public function install(Request $request, Migrator $migrator, LaravelMigrations password: $request->input('account.password'), email: $email, site: $site, + timezone: $request->input('site.timezone'), )->silent(); // Run the install migration From 7b3bf885abec397999afc679cfd05d51a24643e4 Mon Sep 17 00:00:00 2001 From: August Miller Date: Tue, 5 May 2026 12:08:51 -0700 Subject: [PATCH 07/14] Preprocess timezone options in general settings --- .../Controllers/Settings/GeneralSettingsController.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Http/Controllers/Settings/GeneralSettingsController.php b/src/Http/Controllers/Settings/GeneralSettingsController.php index c2ca59ee457..7115f2e32b0 100644 --- a/src/Http/Controllers/Settings/GeneralSettingsController.php +++ b/src/Http/Controllers/Settings/GeneralSettingsController.php @@ -31,13 +31,13 @@ public function __construct( public function index(GeneralConfig $generalConfig): Response|View { + $timezoneOptions = SelectOptions::getTimeZoneOptions(); + $timezoneOptions = array_merge($timezoneOptions, SelectOptions::getEnvOptions(array_column($timezoneOptions, 'value'))); + return Inertia::render('SettingsGeneralPage', [ 'system' => $this->projectConfig->get('system') ?? [], 'nameSuggestions' => SelectOptions::getEnvSuggestions(), - 'timezoneOptions' => [ - ...SelectOptions::getTimeZoneOptions(), - ...SelectOptions::getEnvOptions(), - ], + 'timezoneOptions' => $timezoneOptions, 'crumbs' => [ ['label' => t('Settings'), 'url' => Url::cpUrl('settings')], ['label' => t('General Settings')], From 3869e9b2a6e3ff80d1be4b409fce592621b701f3 Mon Sep 17 00:00:00 2001 From: August Miller Date: Tue, 5 May 2026 12:13:17 -0700 Subject: [PATCH 08/14] Remove unused imports --- src/Http/Controllers/Settings/GeneralSettingsController.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Http/Controllers/Settings/GeneralSettingsController.php b/src/Http/Controllers/Settings/GeneralSettingsController.php index 7115f2e32b0..2cc15218636 100644 --- a/src/Http/Controllers/Settings/GeneralSettingsController.php +++ b/src/Http/Controllers/Settings/GeneralSettingsController.php @@ -8,14 +8,12 @@ use CraftCms\Cms\Cp\SelectOptions; use CraftCms\Cms\Http\RespondsWithFlash; use CraftCms\Cms\ProjectConfig\ProjectConfig; -use CraftCms\Cms\Support\Env; use CraftCms\Cms\Support\Url; use CraftCms\Cms\Validation\Rules\EnvValueRule; use CraftCms\Cms\Validation\Rules\TimezoneRule; use Illuminate\Contracts\View\View; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Validator; use Inertia\Inertia; use Inertia\Response; From 779e7c4a785eb6b28fa5469912534b819ce1bbd0 Mon Sep 17 00:00:00 2001 From: August Miller Date: Tue, 5 May 2026 17:39:04 -0700 Subject: [PATCH 09/14] `nullable` means this key may not exist in the request data --- src/Http/Controllers/Settings/GeneralSettingsController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Http/Controllers/Settings/GeneralSettingsController.php b/src/Http/Controllers/Settings/GeneralSettingsController.php index 2cc15218636..477f569cd39 100644 --- a/src/Http/Controllers/Settings/GeneralSettingsController.php +++ b/src/Http/Controllers/Settings/GeneralSettingsController.php @@ -58,7 +58,7 @@ public function store(Request $request): RedirectResponse $systemSettings = $this->projectConfig->get('system') ?? []; $systemSettings['name'] = $settings['name']; $systemSettings['live'] = $settings['live']; - $systemSettings['retryDuration'] = $settings['retryDuration'] ?: null; + $systemSettings['retryDuration'] = $settings['retryDuration'] ?? null; $systemSettings['timeZone'] = $settings['timeZone']; $this->projectConfig->set('system', $systemSettings, 'Update system settings.'); From b9200a50333a343f219fdf3ffaa60af427f44d8d Mon Sep 17 00:00:00 2001 From: August Miller Date: Thu, 7 May 2026 13:08:15 -0700 Subject: [PATCH 10/14] Validate parsed value as URL! --- src/Http/Controllers/InstallController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Http/Controllers/InstallController.php b/src/Http/Controllers/InstallController.php index a80222e68c2..58fb99b9493 100644 --- a/src/Http/Controllers/InstallController.php +++ b/src/Http/Controllers/InstallController.php @@ -151,7 +151,7 @@ public function validateSite(Request $request): Response { $request->validate([ 'name' => ['required', 'string', 'max:255'], - 'baseUrl' => [new EnvValueRule(['nullable', 'string', 'max:255'])], + 'baseUrl' => [new EnvValueRule(['nullable', 'url', 'max:255'])], 'language' => ['required', 'string', 'max:255', new LanguageRule(onlySiteLanguages: false)], 'timezone' => [new EnvValueRule([new TimezoneRule])], ]); From 30f9334b1d335341aef47cb4d3a32f123c99023f Mon Sep 17 00:00:00 2001 From: August Miller Date: Thu, 7 May 2026 13:33:44 -0700 Subject: [PATCH 11/14] Move deferred prop computation into callbacks The data wouldn't be included in the initial response, but we were still spending time assembling it. --- src/Http/Controllers/InstallController.php | 30 ++++++++++------------ 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/src/Http/Controllers/InstallController.php b/src/Http/Controllers/InstallController.php index 58fb99b9493..cfabf7915b1 100644 --- a/src/Http/Controllers/InstallController.php +++ b/src/Http/Controllers/InstallController.php @@ -65,32 +65,30 @@ public function index(GeneralConfig $generalConfig): \Inertia\Response } } - // Grab the license text - $licensePath = Aliases::get('@craftcms/LICENSE.md'); - $license = file_get_contents($licensePath); - $licenseHtml = Str::markdown($license); - // Guess the site name based on the server name $defaultSystemName = $this->defaultSiteName(); $defaultSiteUrl = $this->defaultSiteUrl(); $defaultSiteLanguage = $this->defaultSiteLanguage(); - $locales = I18N::getAllLocales(); $dbConfig = DB::getConfig(); $postCpLoginRedirect = Cms::config()->postCpLoginRedirect; - $localeOptions = collect($locales) - ->map(fn ($locale) => [ - 'id' => $locale->id, - 'value' => $locale->id, - 'label' => $locale->getDisplayName(app()->getLocale()), - 'selected' => $locale->id === $defaultSiteLanguage, - ]); - return Inertia::render('Install', [ 'showDbScreen' => $showDbScreen, 'postCpLoginRedirect' => $postCpLoginRedirect, - 'licenseHtml' => Inertia::defer(fn () => $licenseHtml), - 'localeOptions' => Inertia::defer(fn () => $localeOptions), + 'licenseHtml' => Inertia::defer(function () { + $licensePath = Aliases::get('@craftcms/LICENSE.md'); + $license = file_get_contents($licensePath); + + return Str::markdown($license); + }), + 'localeOptions' => Inertia::defer(function () use ($defaultSiteLanguage) { + return I18N::getAllLocales()->map(fn ($locale) => [ + 'id' => $locale->id, + 'value' => $locale->id, + 'label' => $locale->getDisplayName(app()->getLocale()), + 'selected' => $locale->id === $defaultSiteLanguage, + ]); + }), 'timezone' => Inertia::defer(function () { $timezoneOptions = SelectOptions::getTimeZoneOptions(); From b09b960942f3001aeee6861a229eb151ff325013 Mon Sep 17 00:00:00 2001 From: August Miller Date: Thu, 7 May 2026 13:50:58 -0700 Subject: [PATCH 12/14] Arrow function for single-statement closure --- src/Http/Controllers/InstallController.php | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/Http/Controllers/InstallController.php b/src/Http/Controllers/InstallController.php index cfabf7915b1..ec83ea69b78 100644 --- a/src/Http/Controllers/InstallController.php +++ b/src/Http/Controllers/InstallController.php @@ -81,14 +81,12 @@ public function index(GeneralConfig $generalConfig): \Inertia\Response return Str::markdown($license); }), - 'localeOptions' => Inertia::defer(function () use ($defaultSiteLanguage) { - return I18N::getAllLocales()->map(fn ($locale) => [ - 'id' => $locale->id, - 'value' => $locale->id, - 'label' => $locale->getDisplayName(app()->getLocale()), - 'selected' => $locale->id === $defaultSiteLanguage, - ]); - }), + 'localeOptions' => Inertia::defer(fn () => I18N::getAllLocales()->map(fn ($locale) => [ + 'id' => $locale->id, + 'value' => $locale->id, + 'label' => $locale->getDisplayName(app()->getLocale()), + 'selected' => $locale->id === $defaultSiteLanguage, + ])), 'timezone' => Inertia::defer(function () { $timezoneOptions = SelectOptions::getTimeZoneOptions(); From 5d3cae399b5178b0ae1990277d745402216da3aa Mon Sep 17 00:00:00 2001 From: Rias Date: Sat, 9 May 2026 15:01:15 +0200 Subject: [PATCH 13/14] Increase test coverage --- .../Commands/Install/InstallCommandTest.php | 36 ++++++ .../Controllers/InstallControllerTest.php | 63 ++++++++++- .../GeneralSettingsControllerTest.php | 103 +++++++++++++++++- 3 files changed, 199 insertions(+), 3 deletions(-) create mode 100644 tests/Feature/Console/Commands/Install/InstallCommandTest.php diff --git a/tests/Feature/Console/Commands/Install/InstallCommandTest.php b/tests/Feature/Console/Commands/Install/InstallCommandTest.php new file mode 100644 index 00000000000..861c159f86e --- /dev/null +++ b/tests/Feature/Console/Commands/Install/InstallCommandTest.php @@ -0,0 +1,36 @@ +artisan('craft:install') + ->expectsOutputToContain('Craft is already installed!') + ->assertSuccessful(); +}); + +it('exposes the timezone option in the command signature', function () { + $definition = app(InstallCommand::class)->getDefinition(); + + expect($definition->hasOption('timezone'))->toBeTrue() + ->and($definition->getOption('timezone')->getDescription()) + ->toContain('default timezone'); +}); + +it('exposes site, account and language options in the command signature', function () { + $definition = app(InstallCommand::class)->getDefinition(); + + expect($definition->hasOption('email'))->toBeTrue() + ->and($definition->hasOption('username'))->toBeTrue() + ->and($definition->hasOption('password'))->toBeTrue() + ->and($definition->hasOption('siteName'))->toBeTrue() + ->and($definition->hasOption('siteUrl'))->toBeTrue() + ->and($definition->hasOption('language'))->toBeTrue(); +}); + +it('registers the install command aliases', function () { + $command = app(InstallCommand::class); + + expect($command->getAliases())->toEqualCanonicalizing(['craft:install:craft', 'craft:install/craft']); +}); diff --git a/tests/Feature/Http/Controllers/InstallControllerTest.php b/tests/Feature/Http/Controllers/InstallControllerTest.php index 75b4393ca18..ecd9f9c6156 100644 --- a/tests/Feature/Http/Controllers/InstallControllerTest.php +++ b/tests/Feature/Http/Controllers/InstallControllerTest.php @@ -17,6 +17,11 @@ Cms::setIsInstalled(false); }); +afterEach(function () { + putenv('INSTALL_TIMEZONE'); + putenv('INSTALL_BASE_URL'); +}); + it('shows the install page', function () { Cms::setIsInstalled(false); @@ -24,9 +29,14 @@ ->assertInertia(function (AssertableInertia $page) { $page->component('Install') ->missing('licenseHtml') + ->missing('localeOptions') + ->missing('timezone') ->loadDeferredProps(function (AssertableInertia $reload) { $reload->has('licenseHtml') - ->where('licenseHtml', fn ($html) => str_contains($html, 'Copyright © Pixel & Tonic, Inc.')); + ->where('licenseHtml', fn ($html) => str_contains($html, 'Copyright © Pixel & Tonic, Inc.')) + ->has('localeOptions') + ->has('timezone') + ->where('timezone', fn ($options) => collect($options)->pluck('value')->contains('America/New_York')); }); }) ->assertOk(); @@ -147,8 +157,59 @@ ], 'errors' => ['language'], ], + 'valid timezone' => [ + 'data' => [ + 'name' => 'Craft', + 'baseUrl' => 'http://localhost', + 'language' => 'en', + 'timezone' => 'America/New_York', + ], + 'errors' => [], + ], + 'invalid timezone' => [ + 'data' => [ + 'name' => 'Craft', + 'baseUrl' => 'http://localhost', + 'language' => 'en', + 'timezone' => 'Not/A_Timezone', + ], + 'errors' => ['timezone'], + ], ]); +it('accepts environment variables for the timezone and baseUrl when validating site', function () { + putenv('INSTALL_TIMEZONE=America/New_York'); + putenv('INSTALL_BASE_URL=https://example.test'); + + postJson(action([InstallController::class, 'validateSite']), [ + 'name' => 'Craft', + 'baseUrl' => '$INSTALL_BASE_URL', + 'language' => 'en', + 'timezone' => '$INSTALL_TIMEZONE', + ])->assertOk(); +}); + +it('validates resolved environment variable timezone values when validating site', function () { + putenv('INSTALL_TIMEZONE=Not/A_Timezone'); + + postJson(action([InstallController::class, 'validateSite']), [ + 'name' => 'Craft', + 'baseUrl' => 'http://localhost', + 'language' => 'en', + 'timezone' => '$INSTALL_TIMEZONE', + ])->assertJsonValidationErrors('timezone'); +}); + +it('validates resolved environment variable baseUrl values when validating site', function () { + putenv('INSTALL_BASE_URL=not-an-url'); + + postJson(action([InstallController::class, 'validateSite']), [ + 'name' => 'Craft', + 'baseUrl' => '$INSTALL_BASE_URL', + 'language' => 'en', + ])->assertJsonValidationErrors('baseUrl'); +}); + test('Laravel migration installer can recreate the sessions table', function () { expect(Schema::hasTable('sessions'))->toBeTrue(); diff --git a/tests/Feature/Http/Controllers/Settings/GeneralSettingsControllerTest.php b/tests/Feature/Http/Controllers/Settings/GeneralSettingsControllerTest.php index 4f992cb930f..1e243e82c4c 100644 --- a/tests/Feature/Http/Controllers/Settings/GeneralSettingsControllerTest.php +++ b/tests/Feature/Http/Controllers/Settings/GeneralSettingsControllerTest.php @@ -17,6 +17,13 @@ actingAs(User::find()->one()); }); +afterEach(function () { + putenv('GENERAL_SETTINGS_NAME'); + putenv('GENERAL_SETTINGS_LIVE'); + putenv('GENERAL_SETTINGS_TIMEZONE'); + putenv('GENERAL_SETTINGS_MISSING'); +}); + it('requires authentication', function () { Auth::logout(); @@ -41,12 +48,104 @@ ->assertOk(); }); +it('exposes timezone options on the settings screen', function () { + get(action([GeneralSettingsController::class, 'index'])) + ->assertInertia(fn (AssertableInertia $page) => $page + ->has('timezoneOptions') + ->where('timezoneOptions', fn ($options) => collect($options)->pluck('value')->contains('America/New_York'))) + ->assertOk(); +}); + it('can save settings', function () { post(action([GeneralSettingsController::class, 'store']), [ 'name' => 'A new app name', 'live' => true, + 'retryDuration' => 60, 'timeZone' => 'America/New_York', - ])->assertRedirectBack(); + ])->assertRedirectBack() + ->assertSessionHasNoErrors(); + + expect(ProjectConfig::get('system.name'))->toBe('A new app name') + ->and(ProjectConfig::get('system.live'))->toBe(true) + ->and(ProjectConfig::get('system.retryDuration'))->toBe(60) + ->and(ProjectConfig::get('system.timeZone'))->toBe('America/New_York'); +}); + +it('clears retryDuration when not provided', function () { + post(action([GeneralSettingsController::class, 'store']), [ + 'name' => 'App', + 'live' => true, + 'timeZone' => 'America/New_York', + ])->assertRedirectBack() + ->assertSessionHasNoErrors(); + + expect(ProjectConfig::get('system.retryDuration'))->toBeNull(); +}); + +it('validates required fields', function (array $data, array $errors) { + post(action([GeneralSettingsController::class, 'store']), $data) + ->assertSessionHasErrors($errors); +})->with([ + 'all missing' => [ + 'data' => [], + 'errors' => ['name', 'live', 'timeZone'], + ], + 'invalid timezone' => [ + 'data' => [ + 'name' => 'App', + 'live' => true, + 'timeZone' => 'Not/A_Timezone', + ], + 'errors' => ['timeZone'], + ], + 'invalid live boolean' => [ + 'data' => [ + 'name' => 'App', + 'live' => 'definitely', + 'timeZone' => 'America/New_York', + ], + 'errors' => ['live'], + ], + 'invalid retryDuration' => [ + 'data' => [ + 'name' => 'App', + 'live' => true, + 'retryDuration' => 'soon', + 'timeZone' => 'America/New_York', + ], + 'errors' => ['retryDuration'], + ], +]); - expect(ProjectConfig::get('system.name'))->toBe('A new app name'); +it('can save settings with environment variables', function () { + putenv('GENERAL_SETTINGS_NAME=Env App'); + putenv('GENERAL_SETTINGS_TIMEZONE=America/New_York'); + + post(action([GeneralSettingsController::class, 'store']), [ + 'name' => '$GENERAL_SETTINGS_NAME', + 'live' => true, + 'timeZone' => '$GENERAL_SETTINGS_TIMEZONE', + ])->assertRedirectBack() + ->assertSessionHasNoErrors(); + + expect(ProjectConfig::get('system.name'))->toBe('$GENERAL_SETTINGS_NAME') + ->and(ProjectConfig::get('system.timeZone'))->toBe('$GENERAL_SETTINGS_TIMEZONE'); +}); + +it('validates resolved environment variable timezone values', function () { + putenv('GENERAL_SETTINGS_TIMEZONE=Not/A_Timezone'); + + post(action([GeneralSettingsController::class, 'store']), [ + 'name' => 'App', + 'live' => true, + 'timeZone' => '$GENERAL_SETTINGS_TIMEZONE', + ])->assertSessionHasErrors('timeZone'); +}); + +it('fails required validation for missing environment variables', function () { + post(action([GeneralSettingsController::class, 'store']), [ + 'name' => '$GENERAL_SETTINGS_MISSING', + 'live' => true, + 'timeZone' => 'America/New_York', + ])->assertSessionHasErrors('name'); }); From ab08989794c660e9a0ab1596e547289044ff33ad Mon Sep 17 00:00:00 2001 From: Rias Date: Sat, 9 May 2026 15:03:33 +0200 Subject: [PATCH 14/14] Fix EnvValueRule incorrectly parsing booleans --- src/Validation/Rules/EnvValueRule.php | 15 +++++++++++++- .../GeneralSettingsControllerTest.php | 14 ++++++++++++- .../Validation/Rules/EnvValueRuleTest.php | 20 +++++++++++++++++++ 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/Validation/Rules/EnvValueRule.php b/src/Validation/Rules/EnvValueRule.php index 8a286cfd0e7..6c76685aef1 100644 --- a/src/Validation/Rules/EnvValueRule.php +++ b/src/Validation/Rules/EnvValueRule.php @@ -71,7 +71,9 @@ public function setData(array $data): static #[Override] public function validate(string $attribute, mixed $value, Closure $fail): void { - $parsedValue = is_string($value) ? Env::parse($value) : $value; + $parsedValue = is_string($value) + ? ($this->isBoolean() ? Env::parseBoolean($value) : Env::parse($value)) + : $value; $data = $this->data; data_set($data, $attribute, $parsedValue); @@ -89,6 +91,17 @@ public function validate(string $attribute, mixed $value, Closure $fail): void } } + private function isBoolean(): bool + { + foreach ($this->rules as $rule) { + if ($rule === 'boolean' || $rule === 'bool') { + return true; + } + } + + return false; + } + private function errorMessage(string $message, mixed $rawValue, mixed $parsedValue): string { if ( diff --git a/tests/Feature/Http/Controllers/Settings/GeneralSettingsControllerTest.php b/tests/Feature/Http/Controllers/Settings/GeneralSettingsControllerTest.php index 1e243e82c4c..f3e2346dab5 100644 --- a/tests/Feature/Http/Controllers/Settings/GeneralSettingsControllerTest.php +++ b/tests/Feature/Http/Controllers/Settings/GeneralSettingsControllerTest.php @@ -119,19 +119,31 @@ it('can save settings with environment variables', function () { putenv('GENERAL_SETTINGS_NAME=Env App'); + putenv('GENERAL_SETTINGS_LIVE=true'); putenv('GENERAL_SETTINGS_TIMEZONE=America/New_York'); post(action([GeneralSettingsController::class, 'store']), [ 'name' => '$GENERAL_SETTINGS_NAME', - 'live' => true, + 'live' => '$GENERAL_SETTINGS_LIVE', 'timeZone' => '$GENERAL_SETTINGS_TIMEZONE', ])->assertRedirectBack() ->assertSessionHasNoErrors(); expect(ProjectConfig::get('system.name'))->toBe('$GENERAL_SETTINGS_NAME') + ->and(ProjectConfig::get('system.live'))->toBe('$GENERAL_SETTINGS_LIVE') ->and(ProjectConfig::get('system.timeZone'))->toBe('$GENERAL_SETTINGS_TIMEZONE'); }); +it('validates resolved environment variable live values', function () { + putenv('GENERAL_SETTINGS_LIVE=maybe'); + + post(action([GeneralSettingsController::class, 'store']), [ + 'name' => 'App', + 'live' => '$GENERAL_SETTINGS_LIVE', + 'timeZone' => 'America/New_York', + ])->assertSessionHasErrors('live'); +}); + it('validates resolved environment variable timezone values', function () { putenv('GENERAL_SETTINGS_TIMEZONE=Not/A_Timezone'); diff --git a/tests/Unit/Validation/Rules/EnvValueRuleTest.php b/tests/Unit/Validation/Rules/EnvValueRuleTest.php index 1f83b3f85a2..5b31d22cbfb 100644 --- a/tests/Unit/Validation/Rules/EnvValueRuleTest.php +++ b/tests/Unit/Validation/Rules/EnvValueRuleTest.php @@ -58,6 +58,7 @@ function makeEnvValueRuleValidator(mixed $value, array $rules) putenv('ENV_VALUE_RULE_EMAIL'); putenv('ENV_VALUE_RULE_MAILER'); putenv('ENV_VALUE_RULE_MISSING'); + putenv('ENV_VALUE_RULE_BOOL'); putenv('PASSWORD'); Aliases::remove('@env-value-rule-email'); }); @@ -134,6 +135,25 @@ function (string $attribute, mixed $value, Closure $fail) { 'sensitive environment variable' => ['$PASSWORD', 'Invalid value.', fn () => putenv('PASSWORD=resolved-value')], ]); +it('parses boolean environment variables for the boolean rule', function (string $envValue, bool $shouldPass) { + putenv("ENV_VALUE_RULE_BOOL={$envValue}"); + + $validator = makeEnvValueRuleValidator('$ENV_VALUE_RULE_BOOL', ['required', 'boolean']); + + expect($validator->passes())->toBe($shouldPass); +})->with([ + 'true' => ['true', true], + 'false' => ['false', true], + 'yes' => ['yes', true], + 'no' => ['no', true], + 'on' => ['on', true], + 'off' => ['off', true], + '1' => ['1', true], + '0' => ['0', true], + 'maybe' => ['maybe', false], + 'empty' => ['', false], +]); + it('works from rulesets without mutating the subject value', function () { putenv('ENV_VALUE_RULE_EMAIL=test@example.com'); $component = makeEnvValueRuleTestComponent('$ENV_VALUE_RULE_EMAIL');