diff --git a/.github/workflows/manifest.yml b/.github/workflows/manifest.yml index e03b53d42c..522a735073 100644 --- a/.github/workflows/manifest.yml +++ b/.github/workflows/manifest.yml @@ -15,9 +15,7 @@ jobs: name: Update manifest runs-on: ubuntu-latest env: - phpVersion: "8.4" - extensions: curl, fileinfo, gd, mbstring, openssl, pdo, pdo_sqlite, sqlite3, xml, zip - key: winter-cms-cache-develop + GITHUB_TOKEN: ${{ secrets.COMPOSER_GITHUB_TOKEN }} steps: - name: Checkout changes uses: actions/checkout@v4 @@ -25,8 +23,8 @@ jobs: - name: Install PHP uses: shivammathur/setup-php@v2 with: - php-version: ${{ env.phpVersion }} - extensions: ${{ env.extensions }} + php-version: 8.4 + extensions: curl, fileinfo, gd, mbstring, openssl, pdo, pdo_sqlite, sqlite3, xml, zip - name: Install Composer dependencies run: composer install --no-interaction --no-progress --no-scripts diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 398fe5a813..cb8381a350 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -21,59 +21,30 @@ jobs: runs-on: ${{ matrix.operatingSystem }} name: ${{ matrix.operatingSystem }} / JavaScript env: - nodeVersion: 16 - phpVersion: '8.2' - extensions: curl, fileinfo, gd, mbstring, openssl, pdo, pdo_sqlite, sqlite3, xml, zip - key: winter-cms-cache-develop + COMPOSER_AUTH: '{"github-oauth": {"github.com": "${{ secrets.GITHUB_TOKEN }}"}}' steps: - name: Checkout changes - uses: actions/checkout@v3 - - - name: Setup extension cache - id: extcache - uses: shivammathur/cache-extensions@v1 - with: - php-version: ${{ env.phpVersion }} - extensions: ${{ env.extensions }} - key: ${{ env.key }} - - - name: Cache extensions - uses: actions/cache@v3 - with: - path: ${{ steps.extcache.outputs.dir }} - key: ${{ steps.extcache.outputs.key }} - restore-keys: ${{ steps.extcache.outputs.key }} + uses: actions/checkout@v4 - name: Install PHP uses: shivammathur/setup-php@v2 with: - php-version: ${{ env.phpVersion }} - extensions: ${{ env.extensions }} + php-version: 8.2 + extensions: curl, fileinfo, gd, mbstring, openssl, pdo, pdo_sqlite, sqlite3, xml, zip - name: Install Node uses: actions/setup-node@v3 with: - node-version: ${{ env.nodeVersion }} + node-version: 16 - name: Switch library dependency (develop) if: github.ref == 'refs/heads/develop' || github.base_ref == 'develop' - run: php ./.github/workflows/utilities/library-switcher "dev-develop as 1.2" + run: php ./.github/workflows/utilities/library-switcher "dev-wip/1.3 as 1.3" - name: Switch library dependency (1.2) if: github.head_ref == '1.2' || github.ref == 'refs/heads/1.2' || github.base_ref == '1.2' run: php ./.github/workflows/utilities/library-switcher "1.2.x-dev as 1.2" - - name: Setup dependency cache - id: composercache - run: echo "::set-output name=dir::$(composer config cache-files-dir)" - - - name: Cache dependencies - uses: actions/cache@v3 - with: - path: ${{ steps.composercache.outputs.dir }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} - restore-keys: ${{ runner.os }}-composer- - - name: Install Composer dependencies run: composer install --no-interaction --no-progress --no-scripts @@ -95,41 +66,25 @@ jobs: max-parallel: 8 matrix: operatingSystem: [ubuntu-latest, windows-latest] - phpVersion: ['8.1', '8.2', '8.3', '8.4', '8.5'] + phpVersion: ['8.2', '8.3', '8.4', '8.5'] fail-fast: false runs-on: ${{ matrix.operatingSystem }} name: ${{ matrix.operatingSystem }} / PHP ${{ matrix.phpVersion }} env: - extensions: curl, fileinfo, gd, mbstring, openssl, pdo, pdo_sqlite, sqlite3, xml, zip - key: winter-cms-cache-develop + COMPOSER_AUTH: '{"github-oauth": {"github.com": "${{ secrets.GITHUB_TOKEN }}"}}' steps: - name: Checkout changes - uses: actions/checkout@v3 - - - name: Setup extension cache - id: extcache - uses: shivammathur/cache-extensions@v1 - with: - php-version: ${{ matrix.phpVersion }} - extensions: ${{ env.extensions }} - key: ${{ env.key }} - - - name: Cache extensions - uses: actions/cache@v3 - with: - path: ${{ steps.extcache.outputs.dir }} - key: ${{ steps.extcache.outputs.key }} - restore-keys: ${{ steps.extcache.outputs.key }} + uses: actions/checkout@v4 - name: Install PHP uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.phpVersion }} - extensions: ${{ env.extensions }} + extensions: curl, fileinfo, gd, mbstring, openssl, pdo, pdo_sqlite, sqlite3, xml, zip - name: Switch library dependency (develop) if: github.ref == 'refs/heads/develop' || github.base_ref == 'develop' - run: php ./.github/workflows/utilities/library-switcher "dev-develop as 1.2" + run: php ./.github/workflows/utilities/library-switcher "dev-wip/1.3 as 1.3" - name: Switch library dependency (1.0) if: github.head_ref == '1.0' || github.ref == 'refs/heads/1.0' || github.base_ref == '1.0' @@ -143,17 +98,6 @@ jobs: if: github.head_ref == '1.2' || github.ref == 'refs/heads/1.2' || github.base_ref == '1.2' run: php ./.github/workflows/utilities/library-switcher "1.2.x-dev as 1.2" - - name: Setup dependency cache - id: composercache - run: echo "::set-output name=dir::$(composer config cache-files-dir)" - - - name: Cache dependencies - uses: actions/cache@v3 - with: - path: ${{ steps.composercache.outputs.dir }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} - restore-keys: ${{ runner.os }}-composer- - - name: Install Composer dependencies run: composer install --no-interaction --no-progress --no-scripts @@ -165,10 +109,6 @@ jobs: - name: Run post-update Composer scripts run: php artisan package:discover - - name: Setup problem matchers for PHPUnit - if: matrix.phpVersion == '8.1' - run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" - - name: Run Linting and Tests run: | composer lint diff --git a/.gitignore b/.gitignore index bb60dbc6e3..a20f241e2e 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ composer.lock selenium.php /bootstrap/compiled.php .phpunit.result.cache +.phpunit.cache # Hosting ignores php_errors.log @@ -26,6 +27,7 @@ nbproject .vscode !.devcontainer/.vscode _ide_helper.php +*.code-workspace .zed # Other ignores diff --git a/composer.json b/composer.json index e7faf9d626..97e17f3d04 100644 --- a/composer.json +++ b/composer.json @@ -29,22 +29,29 @@ "source": "https://github.com/wintercms/winter" }, "require": { - "php": ">=8.1", - "winter/storm": "dev-develop as 1.2", - "winter/wn-system-module": "dev-develop", - "winter/wn-backend-module": "dev-develop", - "winter/wn-cms-module": "dev-develop", - "laravel/framework": "^9.1", + "php": "^8.2", + "winter/storm": "dev-wip/1.3 as 1.3", + "winter/wn-system-module": "dev-wip/1.3", + "winter/wn-backend-module": "dev-wip/1.3", + "winter/wn-cms-module": "dev-wip/1.3", + "laravel/framework": "^12.0|^13.0", "wikimedia/composer-merge-plugin": "~2.1.0" }, "require-dev": { - "phpunit/phpunit": "^9.5.8", + "phpunit/phpunit": "^11.0", "mockery/mockery": "^1.4.4", "fakerphp/faker": "^1.9.2", "squizlabs/php_codesniffer": "^3.2", "php-parallel-lint/php-parallel-lint": "^1.0", - "dms/phpunit-arraysubset-asserts": "^0.1.0|^0.2.1" + "dms/phpunit-arraysubset-asserts": "dev-add-phpunit-11-support" }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/pieterocp/phpunit-arraysubset-asserts", + "no-api": true + } + ], "scripts": { "post-create-project-cmd": [ "@php artisan winter:install", @@ -65,7 +72,6 @@ "phpcs --colors -nq --report=\"full\" --extensions=\"php\"" ] }, - "minimum-stability": "dev", "prefer-stable": true, "extra": { "merge-plugin": { @@ -79,16 +85,6 @@ } }, "config": { - "policy": { - "advisories": { - "ignore": { - "laravel/framework": { - "on-audit": false, - "reason": "Winter CMS 1.2 requires Laravel 9" - } - } - } - }, "allow-plugins": { "composer/installers": true, "wikimedia/composer-merge-plugin": true diff --git a/config/app.php b/config/app.php index e4a78cb57e..e1ec05c6eb 100644 --- a/config/app.php +++ b/config/app.php @@ -2,6 +2,32 @@ return [ + /* + |-------------------------------------------------------------------------- + | Application Name + |-------------------------------------------------------------------------- + | + | This value is the name of your application. This value is used when the + | framework needs to place the application's name in a notification or + | any other location as required by the application or its packages. + | + */ + + 'name' => env('APP_NAME', 'Winter CMS'), + + /* + |-------------------------------------------------------------------------- + | Application Environment + |-------------------------------------------------------------------------- + | + | This value determines the "environment" your application is currently + | running in. This may determine how you prefer to configure various + | services the application utilizes. Set this in your ".env" file. + | + */ + + 'env' => env('APP_ENV', 'production'), + /* |-------------------------------------------------------------------------- | Application Debug Mode @@ -20,20 +46,7 @@ | */ - 'debug' => env('APP_DEBUG', true), - - /* - |-------------------------------------------------------------------------- - | Application Name - |-------------------------------------------------------------------------- - | - | This value is the name of your application. This value is used when the - | framework needs to place the application's name in a notification or - | any other location as required by the application or its packages. - | - */ - - 'name' => env('APP_NAME', 'Winter CMS'), + 'debug' => (bool) env('APP_DEBUG', true), /* |-------------------------------------------------------------------------- @@ -42,7 +55,7 @@ | | This URL is used by the console to properly generate URLs when using | the Artisan command line tool. You should set this to the root of - | your application so that it is used when running Artisan tasks. + | the application so that it's available within Artisan commands. | */ @@ -183,7 +196,6 @@ | will be used by the PHP date and date-time functions. We have gone | ahead and set this to a sensible default for you out of the box. | - | | -------- STOP! -------- | Before you change this value, consider carefully if that is actually | what you want to do. It is HIGHLY recommended that this is always set @@ -225,7 +237,7 @@ | */ - 'locale' => 'en', + 'locale' => env('APP_LOCALE', 'en'), /* |-------------------------------------------------------------------------- @@ -238,7 +250,7 @@ | */ - 'fallback_locale' => 'en', + 'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'), /* |-------------------------------------------------------------------------- @@ -251,7 +263,7 @@ | */ - 'faker_locale' => 'en_US', + 'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'), /* |-------------------------------------------------------------------------- @@ -264,9 +276,16 @@ | */ - 'key' => env('APP_KEY'), 'cipher' => 'AES-256-CBC', + 'key' => env('APP_KEY'), + + 'previous_keys' => [ + ...array_filter( + explode(',', (string) env('APP_PREVIOUS_KEYS', '')) + ), + ], + /* |-------------------------------------------------------------------------- | Autoloaded Service Providers diff --git a/config/hashing.php b/config/hashing.php index ad56664fb1..7bf45fba5c 100644 --- a/config/hashing.php +++ b/config/hashing.php @@ -48,4 +48,17 @@ 'threads' => 1, 'time' => 4, ], + + /* + |-------------------------------------------------------------------------- + | Password Rehashing + |-------------------------------------------------------------------------- + | + | Laravel 11 will automatically rehash your user's passwords during + | authentication if your hashing algorithm's "work factor" has been updated + | since the password was last hashed. + | + */ + + 'rehash_on_login' => false, ]; diff --git a/modules/backend/composer.json b/modules/backend/composer.json index bb1830994c..1b613a9d29 100644 --- a/modules/backend/composer.json +++ b/modules/backend/composer.json @@ -23,9 +23,9 @@ } ], "require": { - "php": ">=8.1", + "php": "^8.2", "composer/installers": "~1.11.0", - "laravel/framework": "^9.1" + "laravel/framework": "^12.0|^13.0" }, "replace": { "october/backend": "1.1.*" diff --git a/modules/backend/controllers/Index.php b/modules/backend/controllers/Index.php index 298a51a24a..315145b75b 100644 --- a/modules/backend/controllers/Index.php +++ b/modules/backend/controllers/Index.php @@ -1,13 +1,16 @@ -url); } diff --git a/modules/backend/formwidgets/DatePicker.php b/modules/backend/formwidgets/DatePicker.php index 893c9612ce..b3882b51c2 100644 --- a/modules/backend/formwidgets/DatePicker.php +++ b/modules/backend/formwidgets/DatePicker.php @@ -93,13 +93,13 @@ public function init() if ($this->minDate !== null) { $this->minDate = is_int($this->minDate) - ? Carbon::createFromTimestamp($this->minDate) + ? Carbon::createFromTimestamp($this->minDate, date_default_timezone_get()) : Carbon::parse($this->minDate); } if ($this->maxDate !== null) { $this->maxDate = is_int($this->maxDate) - ? Carbon::createFromTimestamp($this->maxDate) + ? Carbon::createFromTimestamp($this->maxDate, date_default_timezone_get()) : Carbon::parse($this->maxDate); } } diff --git a/modules/backend/phpunit.xml b/modules/backend/phpunit.xml index 7c0631aa89..2023f43f78 100644 --- a/modules/backend/phpunit.xml +++ b/modules/backend/phpunit.xml @@ -1,17 +1,16 @@ - - - - ./tests - - + + + ./tests + + diff --git a/modules/backend/tests/classes/AuthManagerTest.php b/modules/backend/tests/classes/AuthManagerTest.php index 7b4ed3fb76..2cb7f578d9 100644 --- a/modules/backend/tests/classes/AuthManagerTest.php +++ b/modules/backend/tests/classes/AuthManagerTest.php @@ -2,9 +2,9 @@ namespace Backend\Tests\Classes; +use Backend\Classes\AuthManager; use System\Tests\Bootstrap\TestCase; use Winter\Storm\Exception\SystemException; -use Backend\Classes\AuthManager; class AuthManagerTest extends TestCase { @@ -44,6 +44,7 @@ protected function listNewPermissions() public function tearDown(): void { AuthManager::forgetInstance(); + parent::tearDown(); } public function testListPermissions() diff --git a/modules/backend/tests/classes/ControllerPostbackTest.php b/modules/backend/tests/classes/ControllerPostbackTest.php index 6124f27d03..af2ac8f858 100644 --- a/modules/backend/tests/classes/ControllerPostbackTest.php +++ b/modules/backend/tests/classes/ControllerPostbackTest.php @@ -19,7 +19,7 @@ protected function configAjaxRequestMock(string $handler) $requestMock = $this ->getMockBuilder('Illuminate\Http\Request') ->disableOriginalConstructor() - ->setMethods(['ajax', 'method', 'header', 'secure', 'path', 'getScheme', 'getHost', 'getPort', 'getBaseUrl']) + ->onlyMethods(['ajax', 'method', 'header', 'secure', 'path', 'getScheme', 'getHost', 'getPort', 'getBaseUrl']) ->getMock(); $map = [ @@ -50,7 +50,7 @@ protected function configPostbackRequestMock(string $handler) $requestMock = $this ->getMockBuilder('Illuminate\Http\Request') ->disableOriginalConstructor() - ->setMethods(['ajax', 'method', 'header', 'post', 'input', 'secure', 'path', 'getScheme', 'getHost', 'getPort', 'getBaseUrl']) + ->onlyMethods(['ajax', 'method', 'header', 'post', 'input', 'secure', 'path', 'getScheme', 'getHost', 'getPort', 'getBaseUrl']) ->getMock(); $requestMock->expects($this->any())->method('ajax')->willReturn(false); diff --git a/modules/backend/tests/formwidgets/DatePickerTest.php b/modules/backend/tests/formwidgets/DatePickerTest.php new file mode 100644 index 0000000000..4c658e544b --- /dev/null +++ b/modules/backend/tests/formwidgets/DatePickerTest.php @@ -0,0 +1,71 @@ +originalTimezone = date_default_timezone_get(); + + date_default_timezone_set('Europe/Brussels'); + } + + public function tearDown(): void + { + date_default_timezone_set($this->originalTimezone); + + parent::tearDown(); + } + + /** + * The bounds are passed to the picker as wall times, so a timestamp bound read as UTC moves the + * limit by the UTC offset and can block a date the configuration allows. + */ + public function testTimestampBoundsUseTheDefaultTimezone() + { + $widget = $this->makeWidget([ + 'minDate' => static::TIMESTAMP, + 'maxDate' => static::TIMESTAMP, + ]); + + $this->assertSame(static::LOCAL_DATETIME, $widget->minDate->format('Y-m-d H:i:s')); + $this->assertSame(static::LOCAL_DATETIME, $widget->maxDate->format('Y-m-d H:i:s')); + } + + public function testTimestampBoundsMatchTheirLocalDatetimeEquivalent() + { + $fromTimestamp = $this->makeWidget(['minDate' => static::TIMESTAMP]); + $fromDatetime = $this->makeWidget(['minDate' => static::LOCAL_DATETIME]); + + $this->assertSame( + $fromDatetime->minDate->format('Y-m-d H:i:s'), + $fromTimestamp->minDate->format('Y-m-d H:i:s') + ); + $this->assertSame($fromDatetime->minDate->getTimestamp(), $fromTimestamp->minDate->getTimestamp()); + } + + protected function makeWidget(array $config = []): DatePicker + { + return new DatePicker(new Controller(), new FormField('test', 'Test'), $config); + } +} diff --git a/modules/backend/tests/helpers/BackendHelperTest.php b/modules/backend/tests/helpers/BackendHelperTest.php index b6a120d942..f3142d57ed 100644 --- a/modules/backend/tests/helpers/BackendHelperTest.php +++ b/modules/backend/tests/helpers/BackendHelperTest.php @@ -8,6 +8,34 @@ class BackendHelperTest extends TestCase { + /** + * 2023-11-14 23:30:00 UTC, which is 2023-11-15 00:30:00 in Europe/Brussels. + * The two fall on different calendar days, so a timezone slip changes the date as well as the time. + */ + const TIMESTAMP = 1700004600; + const LOCAL_DATETIME = '2023-11-15 00:30:00'; + + /** + * @var string The default timezone in place before the test ran. + */ + protected $originalTimezone; + + public function setUp(): void + { + parent::setUp(); + + $this->originalTimezone = date_default_timezone_get(); + + date_default_timezone_set('Europe/Brussels'); + } + + public function tearDown(): void + { + date_default_timezone_set($this->originalTimezone); + + parent::tearDown(); + } + public function testDecompileAssets() { $backendHelper = new Backend; @@ -25,4 +53,28 @@ public function testDecompileMissingFile() $backendHelper = new Backend; $assets = $backendHelper->decompileAsset('modules/backend/tests/fixtures/assets/missing.js'); } + + /** + * The datetime attribute is read back by winter.datetime.js as a wall time in the application + * timezone, so a timestamp written to it as UTC is displayed an offset out. + */ + public function testDateTimeWritesTimestampsInTheApplicationTimezone() + { + $backendHelper = new Backend; + + $this->assertStringContainsString( + 'datetime="' . static::LOCAL_DATETIME . '"', + $backendHelper->dateTime(static::TIMESTAMP) + ); + } + + public function testDateTimeRendersATimestampAndItsLocalDatetimeIdentically() + { + $backendHelper = new Backend; + + $this->assertSame( + $backendHelper->dateTime(static::LOCAL_DATETIME, ['formatAlias' => 'dateTimeMin']), + $backendHelper->dateTime(static::TIMESTAMP, ['formatAlias' => 'dateTimeMin']) + ); + } } diff --git a/modules/backend/tests/models/EditorSettingTest.php b/modules/backend/tests/models/EditorSettingTest.php index 66fc0ae4e5..76303aa9fa 100644 --- a/modules/backend/tests/models/EditorSettingTest.php +++ b/modules/backend/tests/models/EditorSettingTest.php @@ -423,7 +423,7 @@ public function testAnUnchangedConfigurationStillReadsBackAsTheDefault() ); } - public function surroundingWhitespaceProvider() + public static function surroundingWhitespaceProvider() { return [ 'a trailing space' => ['oc-text-gray '], @@ -432,7 +432,7 @@ public function surroundingWhitespaceProvider() ]; } - public function legitimateClassNameProvider() + public static function legitimateClassNameProvider() { return [ 'two classes' => ['callout callout--lead'], @@ -450,7 +450,7 @@ public function legitimateClassNameProvider() ]; } - public function nonScalarRowProvider() + public static function nonScalarRowProvider() { return [ 'a label that is an array' => [ @@ -464,7 +464,7 @@ public function nonScalarRowProvider() ]; } - public function styleSettingKeysProvider() + public static function styleSettingKeysProvider() { return [ 'image styles' => ['html_style_image'], diff --git a/modules/backend/tests/traits/InspectableContainerSecurityTest.php b/modules/backend/tests/traits/InspectableContainerSecurityTest.php index 020f75c0cd..99ef73aea9 100644 --- a/modules/backend/tests/traits/InspectableContainerSecurityTest.php +++ b/modules/backend/tests/traits/InspectableContainerSecurityTest.php @@ -105,9 +105,6 @@ public function setUp(): void Config::set('cms.backendUri', 'backend'); Config::set('cms.enableCsrfProtection', false); - // The refusal reason only reaches the error page in debug. - Config::set('app.debug', true); - UnofferedInspectable::$constructed = false; $this->autoloaded = []; @@ -196,15 +193,15 @@ protected function getOptions(string $url, string $className, string $property) } /** - * From the error title, not the body -- the body quotes the surrounding source, messages included. + * From the exception, not the body -- the body quotes the surrounding source, messages included. */ protected function refusalMessage($response): string { - preg_match('/(.*?)<\/title>/s', $response->getContent(), $matches); + $message = $response->exception?->getMessage() ?? ''; - $this->assertNotEmpty($matches[1] ?? '', 'The response should report a reason'); + $this->assertNotEmpty($message, 'The response should report a reason'); - return $matches[1]; + return $message; } protected function assertWasNotLookedUp(string $className): void diff --git a/modules/backend/tests/traits/WidgetMakerTest.php b/modules/backend/tests/traits/WidgetMakerTest.php index 4d1caf2df9..be125946c3 100644 --- a/modules/backend/tests/traits/WidgetMakerTest.php +++ b/modules/backend/tests/traits/WidgetMakerTest.php @@ -9,6 +9,8 @@ class ExampleTraitClass { use \Backend\Traits\WidgetMaker; + protected Controller $controller; + public function __construct() { $this->controller = new Controller; @@ -35,8 +37,9 @@ public function setUp(): void { parent::setUp(); - $traitName = 'Backend\Traits\WidgetMaker'; - $this->traitObject = $this->getObjectForTrait($traitName); + $this->traitObject = new class { + use \Backend\Traits\WidgetMaker; + }; } public function testTraitObject() diff --git a/modules/backend/tests/widgets/BackendMarkupEscapingTest.php b/modules/backend/tests/widgets/BackendMarkupEscapingTest.php index 4978865fb5..a95d6a082e 100644 --- a/modules/backend/tests/widgets/BackendMarkupEscapingTest.php +++ b/modules/backend/tests/widgets/BackendMarkupEscapingTest.php @@ -268,7 +268,7 @@ public function testRichEditorStyleAttributePreservesAQuotedLabel() $this->assertSame(['lead' => 'He said "hi"'], $styles); } - public function richEditorStyleAttributeProvider(): array + public static function richEditorStyleAttributeProvider(): array { return [ 'image styles' => ['html_style_image', 'data-image-styles'], diff --git a/modules/backend/tests/widgets/ReportContainerSecurityTest.php b/modules/backend/tests/widgets/ReportContainerSecurityTest.php index 989c5adb76..d848a1b639 100644 --- a/modules/backend/tests/widgets/ReportContainerSecurityTest.php +++ b/modules/backend/tests/widgets/ReportContainerSecurityTest.php @@ -132,9 +132,6 @@ public function setUp(): void Config::set('cms.backendUri', 'backend'); Config::set('cms.enableCsrfProtection', false); - // refusalMessage() reads the reason off the error page, which only carries it in debug. - Config::set('app.debug', true); - $this->forgetDashboardCaches(); // registerReportWidgets() is gated on runningInBackend(), false here, so the three config_dashboard.yaml defaults need registering or makeReportWidget() drops them. @@ -290,15 +287,15 @@ protected function assertNoLiveMarkup(string $html): void } /** - * From the error title, not the body -- the body quotes the surrounding source, messages included. + * From the exception, not the body -- the body quotes the surrounding source, messages included. */ protected function refusalMessage($response): string { - preg_match('/<title>(.*?)<\/title>/s', $response->getContent(), $matches); + $message = $response->exception?->getMessage() ?? ''; - $this->assertNotEmpty($matches[1] ?? '', 'The response should report a reason'); + $this->assertNotEmpty($message, 'The response should report a reason'); - return $matches[1]; + return $message; } /** @@ -552,7 +549,7 @@ protected function publishStoredLayout($value, int $count): void SystemParameters::set('backend::reportwidgets.default.dashboard', $layout); } - public function widgetCountProvider(): array + public static function widgetCountProvider(): array { return [ 'one widget' => [1], @@ -563,7 +560,7 @@ public function widgetCountProvider(): array /** * Every `fields` shape that names no property the container publishes. */ - public function unusablePayloadProvider(): array + public static function unusablePayloadProvider(): array { return [ 'plain string' => ['not a set of values'], @@ -663,7 +660,7 @@ public function testAddingAWidgetToALayoutWithAnUntypedOrderStoresAnInteger($val $this->assertIsInt($stored[reset($added)]['sortOrder'], 'The sort order the container derives must be an integer'); } - public function untypedOrderProvider(): array + public static function untypedOrderProvider(): array { return [ 'markup' => [self::INJECTED_MARKUP], diff --git a/modules/cms/classes/CodeParser.php b/modules/cms/classes/CodeParser.php index 3d022ad7a8..75d05fa47c 100644 --- a/modules/cms/classes/CodeParser.php +++ b/modules/cms/classes/CodeParser.php @@ -160,7 +160,7 @@ protected function rebuild($path) // Attempt to load the generated code file to ensure any errors are thrown // before the file is cached if (!class_exists($className)) { - require_once $path; + $this->requireClassFile($path); } return $className; @@ -179,7 +179,7 @@ public function source($page, $layout, $controller) $className = $data['className']; if (!class_exists($className)) { - require_once $data['filePath']; + $this->requireClassFile($data['filePath']); } if (!class_exists($className) && ($data = $this->handleCorruptCache($data))) { @@ -200,6 +200,8 @@ protected function handleCorruptCache($data) $path = array_get($data, 'filePath', $this->getCacheFilePath()); if (is_file($path)) { + $this->requireClassFile($path); + if (($className = $this->extractClassFromFile($path)) && class_exists($className)) { $data['className'] = $className; return $data; @@ -311,6 +313,24 @@ protected function extractClassFromFile($path) return null; } + /** + * Loads a cache file unless the class it declares is already loaded. + * @param string $path + * @return void + */ + protected function requireClassFile($path) + { + if (!is_file($path)) { + return; + } + + $className = $this->extractClassFromFile($path); + + if ($className && !class_exists($className, false)) { + require $path; + } + } + /** * Writes content with concurrency support and cache busting * This work is based on the Twig\Cache\FilesystemCache class diff --git a/modules/cms/classes/ComponentManager.php b/modules/cms/classes/ComponentManager.php index 5388c53ac4..01858ce7c1 100644 --- a/modules/cms/classes/ComponentManager.php +++ b/modules/cms/classes/ComponentManager.php @@ -1,9 +1,11 @@ -<?php namespace Cms\Classes; +<?php + +namespace Cms\Classes; -use Str; -use System\Classes\PluginManager; -use SystemException; use Illuminate\Support\Facades\App; +use System\Classes\PluginManager; +use Winter\Storm\Exception\SystemException; +use Winter\Storm\Support\Str; /** * Component manager diff --git a/modules/cms/composer.json b/modules/cms/composer.json index c4c7719ad2..b966e9ed3c 100644 --- a/modules/cms/composer.json +++ b/modules/cms/composer.json @@ -23,9 +23,9 @@ } ], "require": { - "php": ">=8.1", + "php": "^8.2", "composer/installers": "~1.11.0", - "laravel/framework": "^9.1" + "laravel/framework": "^12.0|^13.0" }, "replace": { "october/cms": "1.1.*" diff --git a/modules/cms/phpunit.xml b/modules/cms/phpunit.xml index 4cb34b9f38..bb4330c7dc 100644 --- a/modules/cms/phpunit.xml +++ b/modules/cms/phpunit.xml @@ -1,17 +1,16 @@ <?xml version="1.0" encoding="UTF-8"?> -<phpunit +<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" backupGlobals="false" - backupStaticAttributes="false" + backupStaticProperties="false" + cacheDirectory=".phpunit.cache" colors="true" - convertErrorsToExceptions="true" - convertNoticesToExceptions="true" - convertWarningsToExceptions="true" processIsolation="false" stopOnFailure="false" + xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd" > - <testsuites> - <testsuite name="Winter CMS Module"> - <directory>./tests</directory> - </testsuite> - </testsuites> + <testsuites> + <testsuite name="Winter CMS Module"> + <directory>./tests</directory> + </testsuite> + </testsuites> </phpunit> diff --git a/modules/cms/tests/classes/CmsObjectTest.php b/modules/cms/tests/classes/CmsObjectTest.php index 61bacb711a..02b83c1170 100644 --- a/modules/cms/tests/classes/CmsObjectTest.php +++ b/modules/cms/tests/classes/CmsObjectTest.php @@ -5,6 +5,7 @@ use System\Tests\Bootstrap\TestCase; use Cms\Classes\CmsObject; use Cms\Classes\Theme; +use PHPUnit\Framework\Attributes\Depends; class TestCmsObject extends CmsObject { @@ -236,9 +237,7 @@ public function testSave() $this->assertEquals($testContents, file_get_contents($destFilePath)); } - /** - * @depends testSave - */ + #[Depends('testSave')] public function testRename() { $theme = Theme::load('apitest'); @@ -265,9 +264,7 @@ public function testRename() $this->assertEquals($testContents, file_get_contents($destFilePath)); } - /** - * @depends testRename - */ + #[Depends('testRename')] public function testSaveSameName() { $theme = Theme::load('apitest'); @@ -288,9 +285,7 @@ public function testSaveSameName() $this->assertEquals($testContents, file_get_contents($filePath)); } - /** - * @depends testRename - */ + #[Depends('testRename')] public function testRenameToExistingFile() { $this->expectException(\Winter\Storm\Exception\ApplicationException::class); diff --git a/modules/cms/tests/classes/CodeParserRebuildTest.php b/modules/cms/tests/classes/CodeParserRebuildTest.php new file mode 100644 index 0000000000..ec59628061 --- /dev/null +++ b/modules/cms/tests/classes/CodeParserRebuildTest.php @@ -0,0 +1,85 @@ +<?php + +namespace Cms\Tests\Classes; + +use Cms\Classes\CodeParser; +use Cms\Classes\Layout; +use Cms\Classes\Theme; +use System\Tests\Bootstrap\TestCase; + +/** + * rebuild() writes a randomly named class to a fixed path, so require_once loads only the first + * rebuild of a given object in a process. Later rebuilds, including ones written by another + * process, left their class undeclared. + */ +class CodeParserRebuildTest extends TestCase +{ + public function tearDown(): void + { + // The parser's request cache is static and would otherwise reach the next test. + (new \ReflectionProperty(CodeParser::class, 'cache'))->setValue(null, []); + + parent::tearDown(); + } + + protected function makeParser(): CodeParser + { + $layout = Layout::load(Theme::load('test'), 'php-parser-test.htm'); + + $this->assertNotEmpty($layout, 'The fixture layout the parser tests share is missing.'); + + return new CodeParser($layout); + } + + public function testRebuildingTwiceInOneProcessLeavesTheClassLoadable() + { + $parser = $this->makeParser(); + + $first = $this->rebuild($parser); + $this->assertTrue(class_exists($first), 'The first rebuild must be loadable.'); + + $second = $this->rebuild($parser); + + $this->assertNotSame( + $first, + $second, + 'Each rebuild should generate a new class name.' + ); + $this->assertTrue( + class_exists($second), + 'The second rebuild should be loadable.' + ); + } + + public function testSourceLoadsARebuildWrittenByAnotherProcess() + { + $parser = $this->makeParser(); + $reflection = new \ReflectionObject($parser); + + $first = $this->rebuild($parser); + $path = $reflection->getMethod('getCacheFilePath')->invoke($parser); + + // Another process rebuilds the same object: same path, new class name. + $second = 'Cms' . str_replace('.', '', uniqid('', true)) . '_' . md5(mt_rand()) . 'Class'; + file_put_contents($path, str_replace($first, $second, file_get_contents($path))); + $reflection->getMethod('storeCachedInfo')->invoke($parser, [ + 'filePath' => $path, + 'className' => $second, + 'source' => 'cache', + 'offset' => 0, + ]); + + $this->assertInstanceOf($second, $parser->source(null, null, null)); + } + + /** + * Invokes the protected rebuild() against the path the parser itself would use. + */ + protected function rebuild(CodeParser $parser): string + { + $reflection = new \ReflectionObject($parser); + $path = $reflection->getMethod('getCacheFilePath')->invoke($parser); + + return $reflection->getMethod('rebuild')->invoke($parser, $path); + } +} diff --git a/modules/cms/tests/classes/ControllerPostbackTest.php b/modules/cms/tests/classes/ControllerPostbackTest.php index 8f18741908..4dab2e53e1 100644 --- a/modules/cms/tests/classes/ControllerPostbackTest.php +++ b/modules/cms/tests/classes/ControllerPostbackTest.php @@ -29,7 +29,7 @@ protected function configAjaxRequestMock(string $handler, $partials = false) $requestMock = $this ->getMockBuilder('Illuminate\Http\Request') ->disableOriginalConstructor() - ->setMethods(['ajax', 'method', 'header']) + ->onlyMethods(['ajax', 'method', 'header']) ->getMock(); $map = [ @@ -60,7 +60,7 @@ protected function configPostbackRequestMock(string $handler) $requestMock = $this ->getMockBuilder('Illuminate\Http\Request') ->disableOriginalConstructor() - ->setMethods(['ajax', 'method', 'header', 'post', 'input']) + ->onlyMethods(['ajax', 'method', 'header', 'post', 'input']) ->getMock(); $requestMock->expects($this->any()) diff --git a/modules/cms/tests/classes/ControllerTest.php b/modules/cms/tests/classes/ControllerTest.php index 16d72cb9ce..6a098d6f52 100644 --- a/modules/cms/tests/classes/ControllerTest.php +++ b/modules/cms/tests/classes/ControllerTest.php @@ -2,12 +2,13 @@ namespace Cms\Tests\Classes; -use Cms; -use Request; -use System\Tests\Bootstrap\TestCase; -use Cms\Classes\Theme; use Cms\Classes\Controller; +use Cms\Classes\Theme; +use Cms\Facades\Cms; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Request; use System\Helpers\View; +use System\Tests\Bootstrap\TestCase; use Winter\Storm\Halcyon\Model; use Winter\Storm\Support\Facades\Config; @@ -79,7 +80,7 @@ public function testThemeCombineAssets(): void $cacheKey = 'combiner.' . str_before(basename($url), '-'); // Load the cached config - $combinerConfig = \Cache::get($cacheKey); + $combinerConfig = Cache::get($cacheKey); $this->assertIsString($combinerConfig); // Decode the config @@ -299,30 +300,32 @@ public function testChildThemePageLifeCycle() protected function configAjaxRequestMock($handler, $partials = false) { - $requestMock = $this - ->getMockBuilder('Illuminate\Http\Request') - ->disableOriginalConstructor() - ->setMethods(['ajax', 'method', 'header']) - ->getMock(); - - $map = [ - ['X_WINTER_REQUEST_HANDLER', null, $handler], - ['X_WINTER_REQUEST_PARTIALS', null, $partials], - ]; - - $requestMock->expects($this->any()) - ->method('ajax') - ->will($this->returnValue(true)); - - $requestMock->expects($this->any()) - ->method('method') - ->will($this->returnValue('POST')); - - $requestMock->expects($this->any()) - ->method('header') - ->will($this->returnValueMap($map)); - - return $requestMock; + return new class($handler, $partials) extends \Illuminate\Http\Request { + protected $handler; + protected $partials; + public function __construct($handler, $partials) + { + $this->handler = $handler; + $this->partials = $partials; + parent::__construct(); + } + public function ajax() + { + return true; + } + public function method() + { + return 'POST'; + } + public function header($key = null, $default = null) + { + return match ($key) { + 'X_WINTER_REQUEST_HANDLER' => $this->handler, + 'X_WINTER_REQUEST_PARTIALS' => $this->partials, + default => $default, + }; + } + }; } public function testAjaxHandlerNotFound() diff --git a/modules/cms/tests/classes/ThemeTest.php b/modules/cms/tests/classes/ThemeTest.php index 3e904e8811..9c07ebcd25 100644 --- a/modules/cms/tests/classes/ThemeTest.php +++ b/modules/cms/tests/classes/ThemeTest.php @@ -125,7 +125,7 @@ public function testChildThemeAssetUrl() ); } - public function dirNameValidityProvider(): array + public static function dirNameValidityProvider(): array { return [ // Valid names diff --git a/modules/system/classes/MediaLibraryItem.php b/modules/system/classes/MediaLibraryItem.php index 601c5987ce..0445d0f20f 100644 --- a/modules/system/classes/MediaLibraryItem.php +++ b/modules/system/classes/MediaLibraryItem.php @@ -150,6 +150,6 @@ public function lastModifiedAsString() return null; } - return Carbon::createFromTimestamp($date)->toFormattedDateString(); + return Carbon::createFromTimestamp($date, date_default_timezone_get())->toFormattedDateString(); } } diff --git a/modules/system/classes/UpdateManager.php b/modules/system/classes/UpdateManager.php index e56643d98c..b02f2a9699 100644 --- a/modules/system/classes/UpdateManager.php +++ b/modules/system/classes/UpdateManager.php @@ -135,6 +135,13 @@ public function bindContainerObjects() public function update() { try { + $connection = Schema::getConnection(); + if ($connection->getDriverName() === 'sqlite') { + if (version_compare($connection->getServerVersion(), '3.35', '<')) { + throw new Exception("SQLite version minimum requirement not met (>= 3.35)"); + } + }; + $firstUp = !Schema::hasTable($this->getMigrationTableName()); if ($firstUp) { $this->repository->createRepository(); diff --git a/modules/system/composer.json b/modules/system/composer.json index 6b5c4929d9..5920f4fc82 100644 --- a/modules/system/composer.json +++ b/modules/system/composer.json @@ -23,9 +23,9 @@ } ], "require": { - "php": ">=8.1", + "php": "^8.2", "composer/installers": "~1.11.0", - "laravel/framework": "^9.1", + "laravel/framework": "^12.0|^13.0", "composer/semver": "^3.2" }, "replace": { diff --git a/modules/system/console/WinterTest.php b/modules/system/console/WinterTest.php index 7d24d105c7..5a96ead94c 100644 --- a/modules/system/console/WinterTest.php +++ b/modules/system/console/WinterTest.php @@ -1,6 +1,7 @@ -<?php namespace System\Console; +<?php + +namespace System\Console; -use Config; use Symfony\Component\Process\Exception\ProcessSignaledException; use Symfony\Component\Process\ExecutableFinder; use Symfony\Component\Process\Process; @@ -8,6 +9,7 @@ use Winter\Storm\Console\Command; use Winter\Storm\Exception\ApplicationException; use Winter\Storm\Filesystem\PathResolver; +use Winter\Storm\Support\Facades\Config; use Winter\Storm\Support\Str; /** @@ -33,11 +35,11 @@ class WinterTest extends Command * @var string The console command signature as ignoreValidationErrors causes options not to be registered. */ protected $signature = 'winter:test - {phpunitArgs?* : Arguments to pass through to PHPUnit} - {?--c|configuration= : A specific phpunit xml file} - {?--b|bootstrap= : A custom PHPUnit bootstrap file} - {?--p|plugin=* : List of plugins to test} - {?--m|module=* : List of modules to test} + {phpunitArgs?* : use "--" followed by the arguments to pass through to PHPUnit such as "-- --stop-on-failure"} + {--c|configuration= : A specific phpunit xml file} + {--b|bootstrap= : A custom PHPUnit bootstrap file} + {--p|plugin=* : List of plugins to test} + {--m|module=* : List of modules to test} '; /** @@ -102,6 +104,9 @@ public function handle() foreach (['module', 'plugin'] as $type) { if ($this->option($type)) { foreach ($this->option($type) as $target) { + if (empty($target)) { + continue; + } $target = strtolower($target); if (!isset($configs[$type . 's'][$target])) { throw new ApplicationException(sprintf( @@ -123,8 +128,15 @@ public function handle() return $exitCode; } - // default to running all defined configs found - foreach (['modules', 'plugins'] as $type) { + // default to running all defined configs found, run all tests if no options provided + $types = [ + 'modules' => count($this->option('module')) + !$this->option('plugin'), + 'plugins' => count($this->option('plugin')) + !$this->option('module'), + ]; + foreach ($types as $type => $count) { + if (!$count) { + continue; + } foreach ($configs[$type] as $name => $config) { $this->info( $type === 'plugins' diff --git a/modules/system/console/asset/mix/MixCompile.php b/modules/system/console/asset/mix/MixCompile.php index 14559cb39d..c86d0faf73 100644 --- a/modules/system/console/asset/mix/MixCompile.php +++ b/modules/system/console/asset/mix/MixCompile.php @@ -19,7 +19,6 @@ class MixCompile extends AssetCompile protected $signature = 'mix:compile {webpackArgs?* : Arguments to pass through to the Webpack CLI} {--f|production : Runs compilation in "production" mode} - {--s|silent : Enables silent mode, no output will be shown.} {--d|disable-tty : Disable tty mode} {--e|stop-on-error : Exit once an error is encountered} {--m|manifest= : Defines package.json to use for compile} diff --git a/modules/system/console/asset/mix/MixCreate.php b/modules/system/console/asset/mix/MixCreate.php index 85e1259aee..437b98562f 100644 --- a/modules/system/console/asset/mix/MixCreate.php +++ b/modules/system/console/asset/mix/MixCreate.php @@ -15,7 +15,6 @@ class MixCreate extends AssetCreate protected $signature = 'mix:create {packageName : The package name to add configuration for} {--no-stubs : Disable stub file generation} - {--s|silent : Enables silent mode, no output will be shown.} {--f|force : Force file overwrites}'; /** diff --git a/modules/system/console/asset/mix/MixInstall.php b/modules/system/console/asset/mix/MixInstall.php index b08d8c4b96..272ad6852a 100644 --- a/modules/system/console/asset/mix/MixInstall.php +++ b/modules/system/console/asset/mix/MixInstall.php @@ -17,7 +17,6 @@ class MixInstall extends AssetInstall {--no-install : Tells Winter not to run npm install after config update.} {--npm= : Defines a custom path to the "npm" binary.} {--d|disable-tty : Disable tty mode.} - {--s|silent : Enables silent mode, no output will be shown.} {--p|package-json= : Defines a custom path to "package.json" file. Must be above the workspace path.}'; /** diff --git a/modules/system/console/asset/mix/MixWatch.php b/modules/system/console/asset/mix/MixWatch.php index 53aee49133..b30b84e7b5 100644 --- a/modules/system/console/asset/mix/MixWatch.php +++ b/modules/system/console/asset/mix/MixWatch.php @@ -17,7 +17,6 @@ class MixWatch extends MixCompile {webpackArgs?* : Arguments to pass through to the Webpack CLI} {--f|production : Runs compilation in "production" mode} {--m|manifest= : Defines package.json to use for compile} - {--s|silent : Enables silent mode, no output will be shown.} {--d|disable-tty : Disable tty mode} {--no-progress : Do not show mix progress}'; diff --git a/modules/system/console/asset/npm/NpmInstall.php b/modules/system/console/asset/npm/NpmInstall.php index da697e42ae..c2e89d917a 100644 --- a/modules/system/console/asset/npm/NpmInstall.php +++ b/modules/system/console/asset/npm/NpmInstall.php @@ -25,7 +25,6 @@ class NpmInstall extends NpmCommand {npmArgs?* : Arguments to pass through to the "npm" binary} {--npm= : Defines a custom path to the "npm" binary} {--d|dev : Install packages in devDependencies} - {--s|silent : Silent mode.} {--disable-tty : Disable tty mode}'; /** diff --git a/modules/system/console/asset/npm/NpmRun.php b/modules/system/console/asset/npm/NpmRun.php index cd45313acd..5bfea734d8 100644 --- a/modules/system/console/asset/npm/NpmRun.php +++ b/modules/system/console/asset/npm/NpmRun.php @@ -19,7 +19,6 @@ class NpmRun extends NpmCommand {script : The name of the script to run, as defined in the package.json "scripts" config.} {additionalArgs?* : Arguments to pass through to the script being run.} {--f|production : Runs the script in "production" mode.} - {--s|silent : Silent mode.} {--disable-tty : Disable tty mode}'; /** diff --git a/modules/system/console/asset/npm/NpmUpdate.php b/modules/system/console/asset/npm/NpmUpdate.php index f578c5c4c1..2668391f2b 100644 --- a/modules/system/console/asset/npm/NpmUpdate.php +++ b/modules/system/console/asset/npm/NpmUpdate.php @@ -25,7 +25,6 @@ class NpmUpdate extends NpmCommand {npmArgs?* : Arguments to pass through to the "npm" binary.} {--npm= : Defines a custom path to the "npm" binary.} {--a|save : Tell npm to update package.json.} - {--s|silent : Silent mode.} {--disable-tty : Disable tty mode}'; /** diff --git a/modules/system/console/asset/npm/NpmVersion.php b/modules/system/console/asset/npm/NpmVersion.php index 78e4a4a0b7..0fda33770c 100644 --- a/modules/system/console/asset/npm/NpmVersion.php +++ b/modules/system/console/asset/npm/NpmVersion.php @@ -19,7 +19,6 @@ class NpmVersion extends NpmCommand */ protected $signature = 'npm:version {--c|compatible : Report compatible version via exit code.} - {--s|silent : Silent mode.} {--disable-tty : Disable tty mode}'; /** diff --git a/modules/system/console/asset/vite/ViteCompile.php b/modules/system/console/asset/vite/ViteCompile.php index eb10fea008..5a40904182 100644 --- a/modules/system/console/asset/vite/ViteCompile.php +++ b/modules/system/console/asset/vite/ViteCompile.php @@ -18,7 +18,6 @@ class ViteCompile extends AssetCompile protected $signature = 'vite:compile {viteArgs?* : Arguments to pass through to the Vite CLI} {--f|production : Runs compilation in "production" mode} - {--s|silent : Enables silent mode, no output will be shown.} {--d|disable-tty : Disable tty mode} {--e|stop-on-error : Exit once an error is encountered} {--m|manifest= : Defines package.json to use for compile} diff --git a/modules/system/console/asset/vite/ViteCreate.php b/modules/system/console/asset/vite/ViteCreate.php index 9067fb82ea..a26829a4a6 100644 --- a/modules/system/console/asset/vite/ViteCreate.php +++ b/modules/system/console/asset/vite/ViteCreate.php @@ -17,7 +17,6 @@ class ViteCreate extends AssetCreate protected $signature = 'vite:create {packageName : The package name to add configuration for} {--no-stubs : Disable stub file generation} - {--s|silent : Enables silent mode, no output will be shown.} {--f|force : Force file overwrites}'; /** diff --git a/modules/system/console/asset/vite/ViteInstall.php b/modules/system/console/asset/vite/ViteInstall.php index 035e7328c8..f21c236715 100644 --- a/modules/system/console/asset/vite/ViteInstall.php +++ b/modules/system/console/asset/vite/ViteInstall.php @@ -19,7 +19,6 @@ class ViteInstall extends AssetInstall {--no-install : Tells Winter not to run npm install after config update.} {--npm= : Defines a custom path to the "npm" binary.} {--d|disable-tty : Disable tty mode.} - {--s|silent : Enables silent mode, no output will be shown.} {--p|package-json= : Defines a custom path to "package.json" file. Must be above the workspace path.}'; /** diff --git a/modules/system/console/asset/vite/ViteWatch.php b/modules/system/console/asset/vite/ViteWatch.php index 2825724793..2175f8a94a 100644 --- a/modules/system/console/asset/vite/ViteWatch.php +++ b/modules/system/console/asset/vite/ViteWatch.php @@ -19,7 +19,6 @@ class ViteWatch extends ViteCompile {viteArgs?* : Arguments to pass through to the Webpack CLI} {--f|production : Runs compilation in "production" mode} {--m|manifest= : Defines package.json to use for compile} - {--s|silent : Enables silent mode, no output will be shown.} {--d|disable-tty : Disable tty mode} {--no-progress : Do not show mix progress}'; diff --git a/modules/system/console/scaffold/test/phpunit.stub b/modules/system/console/scaffold/test/phpunit.stub index e7877ce389..e1ab1fedca 100644 --- a/modules/system/console/scaffold/test/phpunit.stub +++ b/modules/system/console/scaffold/test/phpunit.stub @@ -1,13 +1,7 @@ <?xml version="1.0" encoding="UTF-8"?> -<phpunit backupGlobals="false" - backupStaticAttributes="false" - bootstrap="../../../modules/system/tests/bootstrap/app.php" - colors="true" - convertErrorsToExceptions="true" - convertNoticesToExceptions="true" - convertWarningsToExceptions="true" - processIsolation="false" - stopOnFailure="false" +<phpunit + bootstrap="../../../modules/system/tests/bootstrap/app.php" + colors="true" > <testsuites> <testsuite name="Unit"> diff --git a/modules/system/helpers/DateTime.php b/modules/system/helpers/DateTime.php index 62c7f94cad..b944aeaf6e 100644 --- a/modules/system/helpers/DateTime.php +++ b/modules/system/helpers/DateTime.php @@ -57,7 +57,7 @@ public static function makeCarbon($value, $throwException = true) $value = Carbon::instance($value); } elseif (is_numeric($value)) { - $value = Carbon::createFromTimestamp($value); + $value = Carbon::createFromTimestamp($value, date_default_timezone_get()); } elseif (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $value)) { $value = Carbon::createFromFormat('Y-m-d', $value)->startOfDay(); diff --git a/modules/system/phpunit.xml b/modules/system/phpunit.xml index af5eee72a0..66ab407567 100644 --- a/modules/system/phpunit.xml +++ b/modules/system/phpunit.xml @@ -1,17 +1,16 @@ <?xml version="1.0" encoding="UTF-8"?> -<phpunit +<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" backupGlobals="false" - backupStaticAttributes="false" + backupStaticProperties="false" + cacheDirectory=".phpunit.cache" colors="true" - convertErrorsToExceptions="true" - convertNoticesToExceptions="true" - convertWarningsToExceptions="true" processIsolation="false" stopOnFailure="false" + xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd" > - <testsuites> - <testsuite name="Winter System Module"> - <directory>./tests</directory> - </testsuite> - </testsuites> + <testsuites> + <testsuite name="Winter System Module"> + <directory>./tests</directory> + </testsuite> + </testsuites> </phpunit> diff --git a/modules/system/tests/ServiceProviderTest.php b/modules/system/tests/ServiceProviderTest.php index f583e962c9..4ce143ef31 100644 --- a/modules/system/tests/ServiceProviderTest.php +++ b/modules/system/tests/ServiceProviderTest.php @@ -2,9 +2,9 @@ namespace System\Tests; -use Db; -use Log; +use Illuminate\Support\Facades\Log; use System\Tests\Bootstrap\PluginTestCase; +use Winter\Storm\Support\Facades\DB; class ServiceProviderTest extends PluginTestCase { @@ -21,7 +21,7 @@ public function testRegisterLogging() 'key' => 'Dummy value', ]; Log::info($message, $details); - $latestLog = Db::table('system_event_logs')->latest()->first(); + $latestLog = DB::table('system_event_logs')->latest()->first(); $this->assertEquals($message, $latestLog->message); $this->assertEquals($details, json_decode($latestLog->details, true)); } diff --git a/modules/system/tests/bootstrap/PluginTestCase.php b/modules/system/tests/bootstrap/PluginTestCase.php index 5acaa26d5e..b78877be37 100644 --- a/modules/system/tests/bootstrap/PluginTestCase.php +++ b/modules/system/tests/bootstrap/PluginTestCase.php @@ -39,11 +39,7 @@ abstract class PluginTestCase extends TestCase */ public function createApplication() { - $app = require __DIR__ . '/../../../../bootstrap/app.php'; - $app->make('Illuminate\Contracts\Console\Kernel')->bootstrap(); - - $app['cache']->setDefaultDriver('array'); - $app->setLocale('en'); + $app = parent::createApplication(); $app->singleton('backend.auth', function ($app) { $app['auth.loaded'] = true; @@ -60,9 +56,6 @@ public function createApplication() $app['config']->set('database.default', 'testing'); } - // Set random encryption key - $app['config']->set('app.key', bin2hex(random_bytes(16))); - // Modify the plugin path away from the test context $app->setPluginsPath(realpath(base_path() . Config::get('cms.pluginsPath'))); diff --git a/modules/system/tests/bootstrap/TestCase.php b/modules/system/tests/bootstrap/TestCase.php index 3bdb052718..dd324fe061 100644 --- a/modules/system/tests/bootstrap/TestCase.php +++ b/modules/system/tests/bootstrap/TestCase.php @@ -2,8 +2,11 @@ namespace System\Tests\Bootstrap; -use ReflectionClass; +use Illuminate\Contracts\Console\Kernel; +use Illuminate\Contracts\Events\Dispatcher; +use Illuminate\Contracts\Foundation\Application; use PHPUnit\Framework\Assert; +use ReflectionClass; class TestCase extends \Illuminate\Foundation\Testing\TestCase { @@ -24,6 +27,29 @@ public function createApplication() // Set random encryption key $app['config']->set('app.key', bin2hex(random_bytes(16))); + // Override the Kernel call method to prevent symfony shell verbosity breaking scripts. + // @see: https://github.com/symfony/symfony/pull/53632 + // @see: https://github.com/symfony/symfony/pull/24425 + $app->bind(Kernel::class, function (Application $app) { + return new class($app, $app->make(Dispatcher::class)) extends \Winter\Storm\Foundation\Console\Kernel + { + public function call($command, array $parameters = [], $outputBuffer = null) + { + $result = parent::call($command, $parameters, $outputBuffer); + + $shellVerbosity = 0; + + if (\function_exists('putenv')) { + @putenv('SHELL_VERBOSITY=' . $shellVerbosity); + } + + $_ENV['SHELL_VERBOSITY'] = $_SERVER['SHELL_VERBOSITY'] = $shellVerbosity; + + return $result; + } + }; + }); + return $app; } @@ -59,36 +85,26 @@ public static function setProtectedProperty($object, $name, $value) } /** - * Stub for `assertFileNotExists` to allow compatibility with both PHPUnit 8 and 9. - * - * @param string $filename - * @param string $message - * @return void + * Stub for `assertFileNotExists` */ public static function assertFileNotExists(string $filename, string $message = ''): void { - if (method_exists(Assert::class, 'assertFileDoesNotExist')) { - Assert::assertFileDoesNotExist($filename, $message); - return; - } - - Assert::assertFileNotExists($filename, $message); + Assert::assertFileDoesNotExist($filename, $message); } /** - * Stub for `assertRegExp` to allow compatibility with both PHPUnit 8 and 9. - * - * @param string $filename - * @param string $message - * @return void + * Stub for `assertRegExp` */ public static function assertRegExp(string $pattern, string $string, string $message = ''): void { - if (method_exists(Assert::class, 'assertMatchesRegularExpression')) { - Assert::assertMatchesRegularExpression($pattern, $string, $message); - return; - } + Assert::assertMatchesRegularExpression($pattern, $string, $message); + } - Assert::assertRegExp($pattern, $string, $message); + /** + * Stub for `assertObjectHasAttribute` + */ + public static function assertObjectHasAttribute(string $propertyName, $object, string $message = ''): void + { + Assert::assertObjectHasProperty($propertyName, $object, $message); } } diff --git a/modules/system/tests/classes/MediaLibraryItemTest.php b/modules/system/tests/classes/MediaLibraryItemTest.php new file mode 100644 index 0000000000..1c80aa8f97 --- /dev/null +++ b/modules/system/tests/classes/MediaLibraryItemTest.php @@ -0,0 +1,42 @@ +<?php + +namespace System\Tests\Classes; + +use System\Classes\MediaLibraryItem; +use System\Tests\Bootstrap\TestCase; + +class MediaLibraryItemTest extends TestCase +{ + /** + * @var string The default timezone in place before the test ran. + */ + protected $originalTimezone; + + public function setUp(): void + { + parent::setUp(); + + $this->originalTimezone = date_default_timezone_get(); + + date_default_timezone_set('Europe/Brussels'); + } + + public function tearDown(): void + { + date_default_timezone_set($this->originalTimezone); + + parent::tearDown(); + } + + /** + * A file touched just after local midnight is still on the previous day in UTC, so reading its + * timestamp as UTC lists it under the wrong date. + */ + public function testLastModifiedAsStringUsesTheDefaultTimezone() + { + // 2023-11-14 23:30:00 UTC, which is 2023-11-15 00:30:00 in Europe/Brussels + $item = new MediaLibraryItem('/test.jpg', 100, 1700004600, MediaLibraryItem::TYPE_FILE, null); + + $this->assertSame('Nov 15, 2023', $item->lastModifiedAsString()); + } +} diff --git a/modules/system/tests/classes/MediaLibraryTest.php b/modules/system/tests/classes/MediaLibraryTest.php index ac9e67f6ae..751f989f46 100644 --- a/modules/system/tests/classes/MediaLibraryTest.php +++ b/modules/system/tests/classes/MediaLibraryTest.php @@ -4,6 +4,7 @@ use System\Tests\Bootstrap\TestCase; use Illuminate\Filesystem\FilesystemAdapter; +use PHPUnit\Framework\Attributes\DataProvider; use System\Classes\MediaLibrary; class MediaLibraryTest extends TestCase @@ -20,7 +21,7 @@ public function tearDown(): void parent::tearDown(); } - public function invalidPathsProvider() + public static function invalidPathsProvider() { return [ ['./file'], @@ -36,7 +37,7 @@ public function invalidPathsProvider() ]; } - public function validPathsProvider() + public static function validPathsProvider() { return [ ['file'], @@ -61,18 +62,14 @@ public function validPathsProvider() ]; } - /** - * @dataProvider invalidPathsProvider - */ + #[DataProvider('invalidPathsProvider')] public function testInvalidPathsOnValidatePath($path) { $this->expectException('ApplicationException'); MediaLibrary::validatePath($path); } - /** - * @dataProvider validPathsProvider - */ + #[DataProvider('validPathsProvider')] public function testValidPathsOnValidatePath($path) { $result = MediaLibrary::validatePath($path); diff --git a/modules/system/tests/classes/SourceManifestTest.php b/modules/system/tests/classes/SourceManifestTest.php index 3b3ed20ea3..b5a692bdec 100644 --- a/modules/system/tests/classes/SourceManifestTest.php +++ b/modules/system/tests/classes/SourceManifestTest.php @@ -2,10 +2,10 @@ namespace System\Tests\Classes; +use System\Classes\FileManifest; +use System\Classes\SourceManifest; use System\Tests\Bootstrap\TestCase; use Winter\Storm\Argon\Argon; -use System\Classes\SourceManifest; -use System\Classes\FileManifest; class SourceManifestTest extends TestCase { @@ -53,6 +53,7 @@ public function tearDown(): void { Argon::setTestNow(); $this->deleteManifest(); + parent::tearDown(); } public function testCreateManifest() diff --git a/modules/system/tests/classes/VersionManagerTest.php b/modules/system/tests/classes/VersionManagerTest.php index b4d0463a4c..b760e3d617 100644 --- a/modules/system/tests/classes/VersionManagerTest.php +++ b/modules/system/tests/classes/VersionManagerTest.php @@ -2,6 +2,7 @@ namespace System\Tests\Classes; +use PHPUnit\Framework\Attributes\DataProvider; use System\Tests\Bootstrap\TestCase; use System\Classes\VersionManager; @@ -138,13 +139,7 @@ public function testGetNewFileVersions() $this->assertArrayHasKey('1.5.1', $result); } - /** - * @dataProvider versionInfoProvider - * - * @param $versionInfo - * @param $expectedComments - * @param $expectedScripts - */ + #[DataProvider('versionInfoProvider')] public function testExtractScriptsAndComments($versionInfo, $expectedComments, $expectedScripts) { $manager = VersionManager::instance(); @@ -157,7 +152,7 @@ public function testExtractScriptsAndComments($versionInfo, $expectedComments, $ $this->assertEquals($expectedScripts, $scripts); } - public function versionInfoProvider() + public static function versionInfoProvider() { return [ [ diff --git a/modules/system/tests/console/CreateCommandTest.php b/modules/system/tests/console/CreateCommandTest.php index 4bb186ba4a..b65947f17d 100644 --- a/modules/system/tests/console/CreateCommandTest.php +++ b/modules/system/tests/console/CreateCommandTest.php @@ -2,9 +2,8 @@ namespace System\Tests\Console; -use File; -use InvalidArgumentException; use System\Tests\Bootstrap\TestCase; +use Winter\Storm\Support\Facades\File; class CreateCommandTest extends TestCase { diff --git a/modules/system/tests/console/CreateMigrationTest.php b/modules/system/tests/console/CreateMigrationTest.php index 77e30eea71..bac810e607 100644 --- a/modules/system/tests/console/CreateMigrationTest.php +++ b/modules/system/tests/console/CreateMigrationTest.php @@ -2,13 +2,16 @@ namespace System\Tests\Console; -use File; -use Illuminate\Database\Schema\Blueprint; -use Schema; use System\Tests\Bootstrap\PluginTestCase; +use Winter\Storm\Support\Facades\File; +use Winter\Storm\Support\Facades\Schema; class CreateMigrationTest extends PluginTestCase { + protected ?string $versionFile = null; + protected ?string $versionFolder = null; + protected ?string $table = null; + public function setUp(): void { parent::setUp(); @@ -34,46 +37,50 @@ public function testCreateMigration() $columns = [ 'id' => ['type'=>'integer', 'index'=>'primary', 'required'=>true], - 'cb' => ['type'=>'boolean'], - 'switch' => ['type'=>'boolean'], + 'cb' => ['type'=>'tinyint'], + 'switch' => ['type'=>'tinyint'], 'int' => ['type'=>'integer'], 'uint' => ['type'=>'integer', 'required'=>true], - 'double' => ['type'=>'float'], + 'double' => ['type'=>'double'], 'range' => ['type'=>'integer', 'required'=>true], 'datetime' => ['type'=>'datetime'], 'date' => ['type'=>'date', 'required'=>true], 'time' => ['type'=>'time'], 'md' => ['type'=>'text'], 'textarea' => ['type'=>'text'], - 'text' => ['type'=>'string', 'required'=>true], + 'text' => ['type'=>'varchar', 'required'=>true], 'phone_id' => ['type'=>'integer', 'index'=>true, 'required'=>true], 'user_id' => ['type'=>'integer', 'index'=>true, 'required'=>true], 'data' => ['type'=>'text'], 'sort_order' => ['type'=>'integer', 'index'=>true], 'taggable_id' => ['type'=>'integer', 'index'=>'morphable_index'], - 'taggable_type' => ['type'=>'string', 'index'=>'morphable_index'], + 'taggable_type' => ['type'=>'varchar', 'index'=>'morphable_index'], 'created_at' => ['type'=>'datetime'], 'updated_at' => ['type'=>'datetime'], ]; - $table = Schema::getConnection()->getDoctrineSchemaManager()->listTableDetails($this->table); + $schemaBuilder = Schema::getConnection()->getSchemaBuilder(); + + $tableColumns = collect($schemaBuilder->getColumns($this->table)); + $tableIndexes = collect($schemaBuilder->getIndexes($this->table))->pluck('columns', 'name'); foreach ($columns as $name => $definition) { - $this->assertEquals(array_get($definition, 'type'), Schema::getColumnType($this->table, $name)); + $column = $tableColumns->where('name', $name)->first(); + + $this->assertEquals(array_get($definition, 'type'), $column['type_name']); + $this->assertEquals(array_get($definition, 'required', false), !$column['nullable']); // assert an index has been created for the primary, morph and foreign keys if ($indexName = array_get($definition, 'index')) { if ($indexName === true) { $indexName = sprintf("%s_%s_index", $this->table, $name); } - $this->assertTrue($table->hasIndex($indexName)); + $this->assertTrue($schemaBuilder->hasIndex($this->table, $indexName)); - if ($indexName === 'morphable_index') { - $index = $table->getIndex($indexName); - $this->assertTrue(in_array($name, $index->getColumns())); + if ($indexColumns = array_get($indexName, $tableIndexes)) { + $this->assertTrue(in_array($name, $indexColumns)); } } - $this->assertEquals(array_get($definition, 'required', false), $table->getColumn($name)->getNotnull()); } $migration->down(); diff --git a/modules/system/tests/console/WinterUtilTest.php b/modules/system/tests/console/WinterUtilTest.php index f604ba9439..f0ecb84d8b 100644 --- a/modules/system/tests/console/WinterUtilTest.php +++ b/modules/system/tests/console/WinterUtilTest.php @@ -94,5 +94,7 @@ protected function tearDown(): void } $this->artisan('winter:util compile lang')->execute(); + + parent::tearDown(); } } diff --git a/modules/system/tests/console/asset/mix/MixCreateTest.php b/modules/system/tests/console/asset/mix/MixCreateTest.php index f9fcb1f747..5247147269 100644 --- a/modules/system/tests/console/asset/mix/MixCreateTest.php +++ b/modules/system/tests/console/asset/mix/MixCreateTest.php @@ -230,5 +230,7 @@ public function tearDown(): void File::delete($file); } } + + parent::tearDown(); } } diff --git a/modules/system/tests/console/asset/mix/MixInstallTest.php b/modules/system/tests/console/asset/mix/MixInstallTest.php index 1494610628..9127977153 100644 --- a/modules/system/tests/console/asset/mix/MixInstallTest.php +++ b/modules/system/tests/console/asset/mix/MixInstallTest.php @@ -18,16 +18,16 @@ public function setUp(): void { parent::setUp(); - if (!File::exists(base_path('node_modules'))) { - $this->markTestSkipped('This test requires node_modules to be installed'); - } - // Define some helpful paths $this->fixturePath = base_path('modules/system/tests'); $this->jsonPath = $this->fixturePath . '/package.json'; $this->lockPath = $this->fixturePath . '/package-lock.json'; $this->backupPath = $this->fixturePath . '/package-testing.json'; + if (!File::exists(base_path('node_modules'))) { + $this->markTestSkipped('This test requires node_modules to be installed'); + } + // Add our testing theme because it won't be auto discovered PackageManager::instance()->registerPackage( 'theme-assettest', diff --git a/modules/system/tests/console/asset/npm/NpmInstallTest.php b/modules/system/tests/console/asset/npm/NpmInstallTest.php index 611ab20df7..8b28c57894 100644 --- a/modules/system/tests/console/asset/npm/NpmInstallTest.php +++ b/modules/system/tests/console/asset/npm/NpmInstallTest.php @@ -20,16 +20,16 @@ public function setUp(): void { parent::setUp(); - if (!File::exists(base_path('node_modules'))) { - $this->markTestSkipped('This test requires node_modules to be installed'); - } - // Define some helpful paths $this->themePath = base_path('modules/system/tests/fixtures/themes/npmtest'); $this->jsonPath = $this->themePath . '/package.json'; $this->lockPath = $this->themePath . '/package-lock.json'; $this->backupPath = $this->themePath . '/package.backup.json'; + if (!File::exists(base_path('node_modules'))) { + $this->markTestSkipped('This test requires node_modules to be installed'); + } + // Add our testing theme because it won't be auto discovered PackageManager::instance()->registerPackage( 'theme-npmtest', diff --git a/modules/system/tests/console/asset/npm/NpmUpdateTest.php b/modules/system/tests/console/asset/npm/NpmUpdateTest.php index 38b63d9827..7a509c2847 100644 --- a/modules/system/tests/console/asset/npm/NpmUpdateTest.php +++ b/modules/system/tests/console/asset/npm/NpmUpdateTest.php @@ -20,16 +20,16 @@ public function setUp(): void { parent::setUp(); - if (!File::exists(base_path('node_modules'))) { - $this->markTestSkipped('This test requires node_modules to be installed'); - } - // Define some helpful paths $this->themePath = base_path('modules/system/tests/fixtures/themes/npmtest'); $this->jsonPath = $this->themePath . '/package.json'; $this->lockPath = $this->themePath . '/package-lock.json'; $this->backupPath = $this->themePath . '/package.backup.json'; + if (!File::exists(base_path('node_modules'))) { + $this->markTestSkipped('This test requires node_modules to be installed'); + } + // Add our testing theme because it won't be auto discovered PackageManager::instance()->registerPackage( 'theme-npmtest', diff --git a/modules/system/tests/console/asset/vite/ViteCreateTest.php b/modules/system/tests/console/asset/vite/ViteCreateTest.php index cdcfc855ae..db09c2f5b8 100644 --- a/modules/system/tests/console/asset/vite/ViteCreateTest.php +++ b/modules/system/tests/console/asset/vite/ViteCreateTest.php @@ -230,5 +230,7 @@ public function tearDown(): void File::delete($file); } } + + parent::tearDown(); } } diff --git a/modules/system/tests/console/asset/vite/ViteInstallTest.php b/modules/system/tests/console/asset/vite/ViteInstallTest.php index 7d97ccf107..e2138116bc 100644 --- a/modules/system/tests/console/asset/vite/ViteInstallTest.php +++ b/modules/system/tests/console/asset/vite/ViteInstallTest.php @@ -12,21 +12,22 @@ class ViteInstallTest extends TestCase protected string $jsonPath; protected string $lockPath; protected string $backupPath; + protected string $fixturePath; public function setUp(): void { parent::setUp(); - if (!File::exists(base_path('node_modules'))) { - $this->markTestSkipped('This test requires node_modules to be installed'); - } - // Define some helpful paths $this->fixturePath = base_path('modules/system/tests'); $this->jsonPath = $this->fixturePath . '/package.json'; $this->lockPath = $this->fixturePath . '/package-lock.json'; $this->backupPath = $this->fixturePath . '/package-testing.json'; + if (!File::exists(base_path('node_modules'))) { + $this->markTestSkipped('This test requires node_modules to be installed'); + } + // Add our testing theme because it won't be auto discovered PackageManager::instance()->registerPackage( 'theme-assettest', diff --git a/modules/system/tests/fixtures/plugins/winter/tester/components/Comments.php b/modules/system/tests/fixtures/plugins/winter/tester/components/Comments.php index f5e38b5c5a..f0b0e29d31 100644 --- a/modules/system/tests/fixtures/plugins/winter/tester/components/Comments.php +++ b/modules/system/tests/fixtures/plugins/winter/tester/components/Comments.php @@ -8,7 +8,7 @@ class Comments extends ComponentBase { private $users; - public function __construct(?CodeBase $cmsObject = null, $properties = [], ?Users $users = null) + public function __construct(Users $users, ?CodeBase $cmsObject = null, $properties = []) { parent::__construct($cmsObject, $properties); $this->users = $users; diff --git a/modules/system/tests/helpers/DateTimeTest.php b/modules/system/tests/helpers/DateTimeTest.php new file mode 100644 index 0000000000..d92172c3eb --- /dev/null +++ b/modules/system/tests/helpers/DateTimeTest.php @@ -0,0 +1,67 @@ +<?php + +namespace System\Tests\Helpers; + +use System\Helpers\DateTime as DateTimeHelper; +use System\Tests\Bootstrap\TestCase; + +class DateTimeTest extends TestCase +{ + /** + * 2023-11-14 23:30:00 UTC, which is 2023-11-15 00:30:00 in Europe/Brussels. + * The two fall on different calendar days, so a timezone slip changes the date as well as the time. + */ + const TIMESTAMP = 1700004600; + const LOCAL_DATETIME = '2023-11-15 00:30:00'; + + /** + * @var string The default timezone in place before the test ran. + */ + protected $originalTimezone; + + public function setUp(): void + { + parent::setUp(); + + $this->originalTimezone = date_default_timezone_get(); + + date_default_timezone_set('Europe/Brussels'); + } + + public function tearDown(): void + { + date_default_timezone_set($this->originalTimezone); + + parent::tearDown(); + } + + public function testMakeCarbonReadsTimestampsInTheDefaultTimezone() + { + $date = DateTimeHelper::makeCarbon(static::TIMESTAMP); + + $this->assertSame(static::LOCAL_DATETIME, $date->format('Y-m-d H:i:s')); + $this->assertSame(static::TIMESTAMP, $date->getTimestamp()); + } + + /** + * A timestamp and the local datetime it represents must produce the same Carbon instance, so that a + * value stored as a timestamp renders the same as one stored as a datetime string. + */ + public function testMakeCarbonReadsATimestampAndItsLocalDatetimeIdentically() + { + $fromTimestamp = DateTimeHelper::makeCarbon(static::TIMESTAMP); + $fromDatetime = DateTimeHelper::makeCarbon(static::LOCAL_DATETIME); + + $this->assertSame($fromDatetime->format('Y-m-d H:i:s'), $fromTimestamp->format('Y-m-d H:i:s')); + $this->assertSame($fromDatetime->getTimestamp(), $fromTimestamp->getTimestamp()); + } + + public function testTimeTenseReadsTimestampsInTheDefaultTimezone() + { + $this->assertSame('15 Nov 2023 at 00:30', DateTimeHelper::timeTense(static::TIMESTAMP)); + $this->assertSame( + DateTimeHelper::timeTense(static::LOCAL_DATETIME), + DateTimeHelper::timeTense(static::TIMESTAMP) + ); + } +} diff --git a/modules/system/tests/twig/EngineTest.php b/modules/system/tests/twig/EngineTest.php new file mode 100644 index 0000000000..cae107772e --- /dev/null +++ b/modules/system/tests/twig/EngineTest.php @@ -0,0 +1,128 @@ +<?php + +namespace System\Tests\Twig; + +use RuntimeException; +use System\Tests\Bootstrap\TestCase; +use System\Twig\Engine; +use System\Twig\Loader; +use Throwable; +use Twig\Environment; +use Twig\Loader\ArrayLoader; +use Twig\TemplateWrapper; + +class EngineTest extends TestCase +{ + protected function setUp(): void + { + parent::setUp(); + + Loader::$allowInclude = false; + } + + protected function tearDown(): void + { + Loader::$allowInclude = false; + + parent::tearDown(); + } + + /** + * A successful render must leave the include gate exactly as it found it. + */ + public function testIncludeGateIsRestoredAfterASuccessfulRender() + { + $engine = new Engine(new Environment(new ArrayLoader(['ok.htm' => 'rendered']))); + + $this->assertFalse(Loader::$allowInclude); + $this->assertEquals('rendered', $engine->get('ok.htm')); + $this->assertFalse(Loader::$allowInclude); + } + + /** + * The gate must also be restored when loading throws, otherwise arbitrary local file + * inclusion stays enabled for the rest of the process. + */ + public function testIncludeGateIsRestoredWhenLoadingThrows() + { + $engine = new Engine($this->makeThrowingEnvironment()); + + $this->assertFalse(Loader::$allowInclude); + + try { + $engine->get('missing.htm'); + $this->fail('The throwing environment should have propagated its exception.'); + } + catch (RuntimeException $ex) { + $this->assertEquals('Unable to load the template.', $ex->getMessage()); + } + + $this->assertFalse( + Loader::$allowInclude, + 'A thrown load must not leave arbitrary local file inclusion enabled.' + ); + } + + /** + * The security consequence of the above: with the gate leaked, the loader resolves any + * readable local path as a template. + */ + public function testArbitraryLocalFilesCannotResolveAfterAThrownLoad() + { + $engine = new Engine($this->makeThrowingEnvironment()); + $localFile = base_path('composer.json'); + + $this->assertFileExists($localFile); + $this->assertFalse((new Loader())->exists($localFile)); + + try { + $engine->get('missing.htm'); + } + catch (Throwable $ex) { + // Expected; the assertion below covers the state it left behind. + } + + $this->assertFalse( + (new Loader())->exists($localFile), + 'An arbitrary readable file must not resolve as a template after a failed render.' + ); + } + + /** + * The gate is restored to its previous value rather than hard-coded to false, so a nested + * render inside an outer render does not close the outer one's gate early. + */ + public function testIncludeGateIsRestoredToItsPreviousValueNotForcedFalse() + { + $engine = new Engine($this->makeThrowingEnvironment()); + + Loader::$allowInclude = true; + + try { + $engine->get('missing.htm'); + } + catch (Throwable $ex) { + // Expected. + } + + $this->assertTrue( + Loader::$allowInclude, + 'The previous gate value must be restored, not replaced with false.' + ); + } + + /** + * An environment whose template loading always fails, standing in for a missing template or + * a template containing a syntax error. + */ + protected function makeThrowingEnvironment(): Environment + { + return new class (new ArrayLoader([])) extends Environment + { + public function load($name): TemplateWrapper + { + throw new RuntimeException('Unable to load the template.'); + } + }; + } +} diff --git a/modules/system/twig/Engine.php b/modules/system/twig/Engine.php index aff64cbd8c..94557a54c5 100644 --- a/modules/system/twig/Engine.php +++ b/modules/system/twig/Engine.php @@ -31,9 +31,12 @@ public function get($path, array $vars = []) TwigLoader::$allowInclude = true; - $template = $this->environment->load($path); - - TwigLoader::$allowInclude = $previousAllow; + try { + $template = $this->environment->load($path); + } + finally { + TwigLoader::$allowInclude = $previousAllow; + } return $template->render($vars); } diff --git a/phpunit.xml b/phpunit.xml index 72f7f52986..a53e3ba0c2 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,39 +1,34 @@ <?xml version="1.0" encoding="UTF-8"?> <phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - backupGlobals="false" - backupStaticAttributes="false" - bootstrap="./modules/system/tests/bootstrap/app.php" - colors="true" - convertErrorsToExceptions="true" - convertNoticesToExceptions="true" - convertWarningsToExceptions="true" - processIsolation="false" - stopOnFailure="false" - xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd" + bootstrap="./modules/system/tests/bootstrap/app.php" + cacheDirectory=".phpunit.result.cache" + colors="true" + xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd" > - <coverage> - <include> - <directory suffix=".php">./modules/</directory> - </include> - <exclude> - <file>./modules/backend/routes.php</file> - <file>./modules/cms/routes.php</file> - <file>./modules/system/routes.php</file> - <directory suffix=".php">./modules/backend/database</directory> - <directory suffix=".php">./modules/cms/database</directory> - <directory suffix=".php">./modules/system/database</directory> - </exclude> - </coverage> - <testsuites> - <testsuite name="Winter CMS Test Suite"> - <directory>./modules/system</directory> - <directory>./modules/cms</directory> - <directory>./modules/backend</directory> - </testsuite> - </testsuites> - <php> - <env name="APP_ENV" value="testing"/> - <env name="CACHE_DRIVER" value="array"/> - <env name="SESSION_DRIVER" value="array"/> - </php> + <testsuites> + <testsuite name="Winter CMS Test Suite"> + <directory>./modules/system</directory> + <directory>./modules/cms</directory> + <directory>./modules/backend</directory> + </testsuite> + </testsuites> + <php> + <env name="APP_ENV" value="testing"/> + <env name="CACHE_DRIVER" value="array"/> + <env name="SESSION_DRIVER" value="array"/> + <ini name="memory_limit" value="-1"/> + </php> + <source> + <include> + <directory suffix=".php">./modules/</directory> + </include> + <exclude> + <file>./modules/backend/routes.php</file> + <file>./modules/cms/routes.php</file> + <file>./modules/system/routes.php</file> + <directory suffix=".php">./modules/backend/database</directory> + <directory suffix=".php">./modules/cms/database</directory> + <directory suffix=".php">./modules/system/database</directory> + </exclude> + </source> </phpunit>