From cf773b74e3481c389b95b5b47810783c03590161 Mon Sep 17 00:00:00 2001 From: albertlast Date: Tue, 28 Jul 2026 23:02:30 +0200 Subject: [PATCH 01/26] Adds a Docker development environment using PostgreSQL Provides a reproducible local stack so contributors can work on SMF without installing PHP, Composer or PostgreSQL on the host: - PHP 8.4 on Apache, with every extension other/requirements.md lists as required (mbstring, fileinfo, pgsql, mysqli) or recommended (gd, intl, curl, exif, ftp, xsl, zip). - PostgreSQL 17, with standard_conforming_strings forced on at database level as SMF requires. - Mailpit, so mail() is captured locally and nothing can be sent out. - Adminer, for browsing the database. The entrypoint runs composer install, waits for the database, generates a Settings.php pointed at the db service and drops install.php into place, so a fresh checkout is ready to install on first boot. Everything lives under .docker/ because check-smf-index.php and check-smf-license.php skip dot directories, so the environment cannot break the file integrity checks. Co-Authored-By: Claude Opus 5 Signed-off-by: albertlast --- .docker/README.md | 105 ++++++++++++++++++++++++++++++++ .docker/env.example | 17 ++++++ .docker/php/Dockerfile | 47 ++++++++++++++ .docker/php/entrypoint.sh | 92 ++++++++++++++++++++++++++++ .docker/php/msmtprc | 11 ++++ .docker/php/php.ini | 34 +++++++++++ .docker/php/vhost.conf | 19 ++++++ .docker/postgres/init/10-smf.sh | 12 ++++ .gitignore | 7 +++ compose.yaml | 76 +++++++++++++++++++++++ 10 files changed, 420 insertions(+) create mode 100644 .docker/README.md create mode 100644 .docker/env.example create mode 100644 .docker/php/Dockerfile create mode 100644 .docker/php/entrypoint.sh create mode 100644 .docker/php/msmtprc create mode 100644 .docker/php/php.ini create mode 100644 .docker/php/vhost.conf create mode 100644 .docker/postgres/init/10-smf.sh create mode 100644 compose.yaml diff --git a/.docker/README.md b/.docker/README.md new file mode 100644 index 00000000000..d230980f984 --- /dev/null +++ b/.docker/README.md @@ -0,0 +1,105 @@ +# SMF development environment (PostgreSQL) + +A throwaway, reproducible local stack for working on SMF 3.0. Nothing here is +part of the shipped forum — it lives in `.docker/` precisely so the CI checks +(`check-smf-index.php`, `check-smf-license.php`) skip it. + +## Requirements + +Docker Desktop (Linux containers). Nothing else — no local PHP, Composer or +PostgreSQL install is needed. + +## Start + +```sh +docker compose up -d --build +``` + +First boot takes a few minutes: it builds the PHP image and runs +`composer install` into `vendor/`. Watch it with `docker compose logs -f web` +and wait for the `[smf-dev] ready` line. + +| Service | URL | Notes | +| -------- | ----------------------- | ---------------------------------------- | +| Forum | http://localhost:8080 | The forum itself | +| Mailpit | http://localhost:8025 | Every mail the forum sends lands here | +| Adminer | http://localhost:8081 | Database browser, pre-pointed at `db` | +| Postgres | `localhost:5433` | For DBeaver/psql/etc. on the host | + +Database credentials are `smf` / `smf` / database `smf` throughout. + +## Installing the forum + +On first boot the entrypoint writes a `Settings.php` pre-filled for the +`db` service and copies `other/install.php` to the web root, so +http://localhost:8080 redirects into the installer. + +The installer does **not** read its form defaults from `Settings.php` — it uses +the hardcoded defaults in `SMF\Db\APIs\PostgreSQL`. On the *Database Server +Settings* step you must enter: + +| Field | Value | +| ------------- | ------------ | +| Database type | `PostgreSQL` | +| Server | `db` | +| Port | `5432` | +| Username | `smf` | +| Password | `smf` | +| Database name | `smf` | + +Port `5432` is correct here: `5433` is only how the host reaches postgres from +outside Docker. Containers talk to each other on the internal network. + +When the installer finishes, delete `install.php` from the repo root — while it +exists, `Settings.php` redirects every request back into the installer. + +## Everyday use + +```sh +docker compose logs -f web # apache + php errors, live +docker compose exec web bash # shell in the web container +docker compose exec db psql -U smf # psql on the forum database + +docker compose exec web composer install +docker compose exec web composer lint + +docker compose restart web # after changing php.ini or the vhost +docker compose down # stop, keep the database +docker compose down -v # stop and destroy the database +``` + +The repository is bind-mounted at `/var/www/html`, so edits on the host are +live on the next request. Opcache is on but revalidates every request, so you +never need to restart for a PHP change. + +To reinstall from scratch: `docker compose down -v`, delete `Settings.php` and +`Settings_bak.php`, then `docker compose up -d`. + +## Configuration + +`compose.yaml` works with no `.env` file. To change ports, versions or +credentials, copy `.docker/env.example` to `.env` in the repository root. + +## What is in the image + +PHP 8.4 on Apache, with everything `other/requirements.md` lists: + +- Required: `mbstring`, `fileinfo`, `pgsql` (SMF checks for `pg_connect`), plus + `mysqli` so the installer still offers MySQL. +- Recommended: `gd`, `intl`, `curl`, `exif`, `ftp`, `xsl`, and `zip`. +- `standard_conforming_strings` is set `on` at database level, as SMF requires. +- `mail()` is routed through msmtp into Mailpit, so no mail can escape the + machine. + +## Files + +``` +compose.yaml the stack +.docker/php/Dockerfile PHP + Apache image +.docker/php/php.ini dev php settings, per requirements.md +.docker/php/vhost.conf apache vhost +.docker/php/msmtprc mail() -> mailpit +.docker/php/entrypoint.sh composer install, Settings.php, permissions +.docker/postgres/init/10-smf.sh runs once on first database creation +.docker/env.example optional overrides +``` diff --git a/.docker/env.example b/.docker/env.example new file mode 100644 index 00000000000..52db52142d6 --- /dev/null +++ b/.docker/env.example @@ -0,0 +1,17 @@ +# Copy to the repository root as `.env` to override any of the defaults. +# compose.yaml works without this file. + +# Host ports +WEB_PORT=8080 +ADMINER_PORT=8081 +MAILPIT_PORT=8025 +POSTGRES_PORT=5433 + +# Versions +PHP_VERSION=8.4 +POSTGRES_VERSION=17-alpine + +# Database credentials (dev only) +POSTGRES_DB=smf +POSTGRES_USER=smf +POSTGRES_PASSWORD=smf diff --git a/.docker/php/Dockerfile b/.docker/php/Dockerfile new file mode 100644 index 00000000000..a9588f1df5d --- /dev/null +++ b/.docker/php/Dockerfile @@ -0,0 +1,47 @@ +# SMF development image: PHP + Apache with everything other/requirements.md asks for. +ARG PHP_VERSION=8.4 +FROM php:${PHP_VERSION}-apache + +COPY --from=mlocati/php-extension-installer:2 /usr/bin/install-php-extensions /usr/local/bin/ +COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer + +# Required by SMF: mbstring, fileinfo, pgsql (pg_connect), mysqli. +# Recommended by SMF: gd, intl, curl, exif, ftp, xsl. +RUN install-php-extensions \ + pgsql \ + pdo_pgsql \ + mysqli \ + mbstring \ + fileinfo \ + gd \ + intl \ + curl \ + exif \ + ftp \ + xsl \ + zip \ + opcache \ + && a2enmod rewrite headers expires \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + git \ + unzip \ + msmtp \ + msmtp-mta \ + postgresql-client \ + && rm -rf /var/lib/apt/lists/* + +# Route mail() at Mailpit so outgoing forum mail is captured, never sent. +COPY .docker/php/msmtprc /etc/msmtprc +RUN chmod 0644 /etc/msmtprc + +COPY .docker/php/php.ini /usr/local/etc/php/conf.d/zz-smf.ini +COPY .docker/php/vhost.conf /etc/apache2/sites-available/000-default.conf +COPY .docker/php/entrypoint.sh /usr/local/bin/smf-entrypoint + +RUN chmod +x /usr/local/bin/smf-entrypoint + +WORKDIR /var/www/html + +ENTRYPOINT ["/usr/local/bin/smf-entrypoint"] +CMD ["apache2-foreground"] diff --git a/.docker/php/entrypoint.sh b/.docker/php/entrypoint.sh new file mode 100644 index 00000000000..676ed275c00 --- /dev/null +++ b/.docker/php/entrypoint.sh @@ -0,0 +1,92 @@ +#!/bin/sh +# Prepares the bind-mounted SMF checkout so the forum is ready to install/serve. +# Everything here is idempotent: it is safe to restart the container at any time. +set -eu + +BOARD_DIR=/var/www/html + +log() { + echo "[smf-dev] $*" +} + +# ---------------------------------------------------------------- dependencies +if [ ! -f "$BOARD_DIR/vendor/autoload.php" ]; then + log 'vendor/ is missing, running composer install (this takes a minute the first time)' + composer install \ + --working-dir="$BOARD_DIR" \ + --no-interaction \ + --no-progress \ + --prefer-dist \ + --ansi +fi + +# ------------------------------------------------------------------- database +log "waiting for postgres at ${SMF_DB_SERVER}:${SMF_DB_PORT}" +until pg_isready -h "$SMF_DB_SERVER" -p "$SMF_DB_PORT" -U "$SMF_DB_USER" -q; do + sleep 1 +done +log 'postgres is accepting connections' + +# --------------------------------------------------------------- installer bits +# Settings.php redirects to install.php whenever install.php is present, so both +# files only get placed while the forum has not been installed yet. +if [ ! -f "$BOARD_DIR/Settings.php" ]; then + log 'generating Settings.php pre-filled for the postgres service' + + sed \ + -e "s|^\$db_type = 'mysql';|\$db_type = 'postgresql';|" \ + -e "s|^\$db_port = 0;|\$db_port = ${SMF_DB_PORT};|" \ + -e "s|^\$db_server = 'localhost';|\$db_server = '${SMF_DB_SERVER}';|" \ + -e "s|^\$db_name = 'smf';|\$db_name = '${SMF_DB_NAME}';|" \ + -e "s|^\$db_user = 'root';|\$db_user = '${SMF_DB_USER}';|" \ + -e "s|^\$db_passwd = '';|\$db_passwd = '${SMF_DB_PASSWD}';|" \ + -e "s|^\$boardurl = 'http://127.0.0.1/smf';|\$boardurl = '${SMF_BOARDURL}';|" \ + -e "s|^\$mbname = 'My Community';|\$mbname = 'SMF Dev';|" \ + "$BOARD_DIR/other/Settings.php" > "$BOARD_DIR/Settings.php" + + cp "$BOARD_DIR/other/install.php" "$BOARD_DIR/install.php" + + log "installer ready -- open ${SMF_BOARDURL}/install.php" +fi + +[ -f "$BOARD_DIR/Settings_bak.php" ] || cp "$BOARD_DIR/Settings.php" "$BOARD_DIR/Settings_bak.php" + +# ------------------------------------------------------------------ writability +# The installer refuses to continue unless all of these are writable, and the +# forum needs them at runtime too. +for path in \ + attachments \ + avatars \ + custom_avatar \ + cache \ + Packages \ + Smileys \ + Themes \ + Languages \ + Sources \ + Settings.php \ + Settings_bak.php \ + Languages/en_US/agreement.txt +do + [ -e "$BOARD_DIR/$path" ] || mkdir -p "$BOARD_DIR/$path" +done + +[ -f "$BOARD_DIR/cache/db_last_error.php" ] || cp "$BOARD_DIR/db_last_error.php" "$BOARD_DIR/cache/db_last_error.php" 2>/dev/null || true + +# Bind mounts from the Windows host ignore chown/chmod, which is harmless. On +# Linux/macOS hosts these calls are what makes the checkout writable by Apache. +chown -R www-data:www-data \ + "$BOARD_DIR/attachments" \ + "$BOARD_DIR/avatars" \ + "$BOARD_DIR/custom_avatar" \ + "$BOARD_DIR/cache" \ + "$BOARD_DIR/Packages" \ + "$BOARD_DIR/Smileys" \ + "$BOARD_DIR/Themes" \ + "$BOARD_DIR/Languages" \ + "$BOARD_DIR/Settings.php" \ + "$BOARD_DIR/Settings_bak.php" 2>/dev/null || true + +log 'ready' + +exec "$@" diff --git a/.docker/php/msmtprc b/.docker/php/msmtprc new file mode 100644 index 00000000000..ce832dcf3b4 --- /dev/null +++ b/.docker/php/msmtprc @@ -0,0 +1,11 @@ +defaults +auth off +tls off +logfile /dev/stderr + +account mailpit +host mailpit +port 1025 +from smf@localhost + +account default : mailpit diff --git a/.docker/php/php.ini b/.docker/php/php.ini new file mode 100644 index 00000000000..6b32f746e7a --- /dev/null +++ b/.docker/php/php.ini @@ -0,0 +1,34 @@ +; SMF development settings. +; Values follow other/requirements.md (Requirements + Recommendations). + +[PHP] +engine = On +file_uploads = On +memory_limit = 512M +max_execution_time = 30 +max_input_time = 60 +post_max_size = 128M +upload_max_filesize = 128M +max_file_uploads = 50 +date.timezone = UTC + +; Dev-only: surface every problem instead of hiding it. +display_errors = On +display_startup_errors = On +error_reporting = E_ALL +log_errors = On +error_log = /dev/stderr + +[Session] +session.use_trans_sid = Off +session.save_path = "/tmp" + +[mail function] +sendmail_path = "/usr/bin/msmtp -t -i" + +[opcache] +; Keep opcache on for realistic behaviour, but always re-read changed files. +opcache.enable = 1 +opcache.enable_cli = 0 +opcache.validate_timestamps = 1 +opcache.revalidate_freq = 0 diff --git a/.docker/php/vhost.conf b/.docker/php/vhost.conf new file mode 100644 index 00000000000..8be73bae7ce --- /dev/null +++ b/.docker/php/vhost.conf @@ -0,0 +1,19 @@ +ServerName localhost + + + DocumentRoot /var/www/html + + + Options -Indexes +FollowSymLinks + AllowOverride All + Require all granted + + + # Nothing under these paths should ever be served. + + Require all denied + + + ErrorLog /dev/stderr + CustomLog /dev/stdout combined + diff --git a/.docker/postgres/init/10-smf.sh b/.docker/postgres/init/10-smf.sh new file mode 100644 index 00000000000..0498c9270ff --- /dev/null +++ b/.docker/postgres/init/10-smf.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# Runs once, on first initialisation of the postgres data volume. +set -eu + +# SMF requires standard_conforming_strings to be on (other/requirements.md). +# It is already the default on modern postgres; setting it at database level +# makes it explicit so it cannot drift out from under the forum. +psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL + ALTER DATABASE "$POSTGRES_DB" SET standard_conforming_strings = on; +EOSQL + +echo "[smf-dev] database $POSTGRES_DB initialised" diff --git a/.gitignore b/.gitignore index 8a574e9b8d5..6b46b9c3a5c 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ attachments/ !/attachments/.htaccess !/attachments/index.php /upgrade.php +/install.php Themes/default/css/minified*.css Themes/default/scripts/minified*.js Themes/default/scripts/minified_deferred*.js @@ -72,6 +73,12 @@ Thumbs.db *.lnk ._* +# Local dev environment # +########################## +/.env +/compose.override.yaml +/compose.override.yml + # Test / Private files # ######################## /nbproject/private/ diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 00000000000..be97189db49 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,76 @@ +# SMF development environment (PostgreSQL). +# +# docker compose up -d --build +# -> http://localhost:8080/install.php +# +# Everything below has a working default, so no .env file is required. Copy +# .docker/env.example to .env if you want to change ports or credentials. + +name: smf-dev + +services: + web: + build: + context: . + dockerfile: .docker/php/Dockerfile + args: + PHP_VERSION: ${PHP_VERSION:-8.4} + ports: + - "${WEB_PORT:-8080}:80" + volumes: + # The checkout is bind-mounted, so edits on the host are live immediately. + - .:/var/www/html + environment: + SMF_DB_SERVER: db + SMF_DB_PORT: "5432" + SMF_DB_NAME: ${POSTGRES_DB:-smf} + SMF_DB_USER: ${POSTGRES_USER:-smf} + SMF_DB_PASSWD: ${POSTGRES_PASSWORD:-smf} + SMF_BOARDURL: http://localhost:${WEB_PORT:-8080} + depends_on: + db: + condition: service_healthy + restart: unless-stopped + + db: + image: postgres:${POSTGRES_VERSION:-17-alpine} + ports: + # Exposed on 5433 by default so it cannot collide with a local postgres. + - "${POSTGRES_PORT:-5433}:5432" + environment: + POSTGRES_DB: ${POSTGRES_DB:-smf} + POSTGRES_USER: ${POSTGRES_USER:-smf} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-smf} + # Dev-only: skip the password prompt cost for local connections. + POSTGRES_HOST_AUTH_METHOD: scram-sha-256 + volumes: + - db-data:/var/lib/postgresql/data + - ./.docker/postgres/init:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-smf} -d ${POSTGRES_DB:-smf}"] + interval: 3s + timeout: 3s + retries: 20 + restart: unless-stopped + + # Catches every mail the forum sends. Nothing leaves the machine. + mailpit: + image: axllent/mailpit:latest + ports: + - "${MAILPIT_PORT:-8025}:8025" + restart: unless-stopped + + # Browse and query the database at http://localhost:8081 + adminer: + image: adminer:latest + ports: + - "${ADMINER_PORT:-8081}:8080" + environment: + ADMINER_DEFAULT_SERVER: db + ADMINER_DESIGN: dracula + depends_on: + - db + restart: unless-stopped + +volumes: + db-data: From 77d46cf02ca162fcc37eb174c0476e7ca9090bf9 Mon Sep 17 00:00:00 2001 From: albertlast Date: Wed, 29 Jul 2026 18:22:45 +0200 Subject: [PATCH 02/26] Gives each action subclass its own instance in ActionTrait::load() ActionTrait declares $obj as a static property, and a static property is shared with every descendant class that does not redeclare it. None of the eleven action classes that extend another action redeclare it, so they all share one slot with their parent. Once the parent has been loaded, load() finds that slot occupied and returns the parent's instance, which does not satisfy the "static" return type: SMF\Actions\Login2::load(): Return value must be of type SMF\Actions\Logout, SMF\Actions\Login2 returned This is reachable during login: User::enforceBans() calls Logout::call() to kick a banned member, by which point Login2 has already been loaded, so a banned member gets a fatal error instead of being logged out. Checks that the cached instance is of the class being loaded, rather than merely present. Co-Authored-By: Claude Opus 5 --- Sources/ActionTrait.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Sources/ActionTrait.php b/Sources/ActionTrait.php index cacd323b991..5cadd786c9e 100644 --- a/Sources/ActionTrait.php +++ b/Sources/ActionTrait.php @@ -115,7 +115,10 @@ public function canShowDebuggingInfo(): bool */ public static function load(): static { - if (!isset(static::$obj)) { + // A static property is shared with every descendant class that doesn't + // redeclare it, so $obj might currently hold an instance of a relative + // of this class rather than an instance of this class itself. + if (!isset(static::$obj) || static::$obj::class !== static::class) { static::$obj = new static(); } From 27463072a99961719a1902752bee132788daa397 Mon Sep 17 00:00:00 2001 From: albertlast Date: Wed, 29 Jul 2026 20:37:01 +0200 Subject: [PATCH 03/26] Adds a PHPUnit suite for code that needs no database SMF has no automated tests. CI proves that the code parses and that it is formatted; it never executes anything. Every one of the bugs fixed in #9319 through #9322 parsed cleanly and passed every check. Quite a lot of 3.0 is reachable without a forum behind it. The bootstrap here defines the constants index.php would define and points the autoloader at Sources/, and that is enough: no Settings.php, no database, no request. Anything that reaches Config::$modSettings, User::$me or Db::$db is out of scope and belongs in an integration suite. The first tests cover ground that recently broke: - ActionTrait::load() returning an instance of the class it was called on, in both orders and in two separate class hierarchies. - CreatePost_Notify::getTimeOffset(), including the half-hour and quarter-hour zones that an int cast used to truncate. - Utils::buildRegex(), including the trailing quoted character from #9318. tests/ is already excluded from the license header check in BuildTools, and the directory index.php files keep check-smf-index happy. Co-Authored-By: Claude Opus 5 --- composer.json | 3 +- composer.lock | 2564 ++++++++++++++++++++++----- phpunit.xml.dist | 20 + tests/Unit/ActionTraitTest.php | 58 + tests/Unit/CreatePostNotifyTest.php | 80 + tests/Unit/UtilsTest.php | 47 + tests/Unit/index.php | 8 + tests/bootstrap.php | 53 + tests/index.php | 8 + 9 files changed, 2443 insertions(+), 398 deletions(-) create mode 100644 phpunit.xml.dist create mode 100644 tests/Unit/ActionTraitTest.php create mode 100644 tests/Unit/CreatePostNotifyTest.php create mode 100644 tests/Unit/UtilsTest.php create mode 100644 tests/Unit/index.php create mode 100644 tests/bootstrap.php create mode 100644 tests/index.php diff --git a/composer.json b/composer.json index c08fcb38487..83b76bad667 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,8 @@ "prefer-stable": true, "require-dev": { "simplemachines/build-tools": "dev-release-3.0", - "friendsofphp/php-cs-fixer": "^3.95" + "friendsofphp/php-cs-fixer": "^3.95", + "phpunit/phpunit": "^13.1" }, "scripts": { "lint": "php-cs-fixer --quiet check --config .php-cs-fixer.dist.php --path-mode=intersection $(git diff --name-only \"*.php\") --allow-risky=yes || php-cs-fixer check --diff --config .php-cs-fixer.dist.php --path-mode=intersection $(git diff --name-only \"*.php\") --allow-risky=yes", diff --git a/composer.lock b/composer.lock index 96f77c79495..8d07af23505 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "72fc34fa5627c56e54537e4b54939e8c", + "content-hash": "50c2bac5d85b523f36379e484a555b9d", "packages": [ { "name": "bjeavons/zxcvbn-php", @@ -1028,6 +1028,123 @@ ], "time": "2026-07-24T13:54:39+00:00" }, + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.8.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" + }, + "time": "2026-07-04T14:30:18+00:00" + }, { "name": "overtrue/phplint", "version": "9.0.4", @@ -1112,491 +1229,780 @@ "time": "2023-02-23T15:46:09+00:00" }, { - "name": "psr/cache", - "version": "3.0.0", + "name": "phar-io/manifest", + "version": "2.0.4", "source": { "type": "git", - "url": "https://github.com/php-fig/cache.git", - "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", - "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", "shasum": "" }, "require": { - "php": ">=8.0.0" + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-master": "2.0.x-dev" } }, "autoload": { - "psr-4": { - "Psr\\Cache\\": "src/" - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" } ], - "description": "Common interface for caching libraries", - "keywords": [ - "cache", - "psr", - "psr-6" - ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", "support": { - "source": "https://github.com/php-fig/cache/tree/3.0.0" + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" }, - "time": "2021-02-03T23:26:27+00:00" + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" }, { - "name": "psr/event-dispatcher", - "version": "1.0.0", + "name": "phar-io/version", + "version": "3.2.1", "source": { "type": "git", - "url": "https://github.com/php-fig/event-dispatcher.git", - "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", - "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", "shasum": "" }, "require": { - "php": ">=7.2.0" + "php": "^7.2 || ^8.0" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" - } - }, "autoload": { - "psr-4": { - "Psr\\EventDispatcher\\": "src/" - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "PHP-FIG", - "homepage": "http://www.php-fig.org/" + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" } ], - "description": "Standard interfaces for event handling.", - "keywords": [ - "events", - "psr", - "psr-14" - ], + "description": "Library for handling version information and constraints", "support": { - "issues": "https://github.com/php-fig/event-dispatcher/issues", - "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" }, - "time": "2019-01-08T18:20:26+00:00" + "time": "2022-02-21T01:04:05+00:00" }, { - "name": "psr/log", - "version": "3.0.2", + "name": "phpunit/php-code-coverage", + "version": "14.2.3", "source": { "type": "git", - "url": "https://github.com/php-fig/log.git", - "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "82f6e49ff224e2cde923d74425e583a883910783" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", - "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/82f6e49ff224e2cde923d74425e583a883910783", + "reference": "82f6e49ff224e2cde923d74425e583a883910783", "shasum": "" }, "require": { - "php": ">=8.0.0" + "ext-dom": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.8.0", + "php": ">=8.4", + "phpunit/php-text-template": "^6.0", + "sebastian/complexity": "^6.0", + "sebastian/environment": "^9.3.2", + "sebastian/git-state": "^1.0", + "sebastian/lines-of-code": "^5.0.1", + "sebastian/version": "^7.0", + "theseer/tokenizer": "^2.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^13.2.2" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.x-dev" + "dev-main": "14.2.x-dev" } }, "autoload": { - "psr-4": { - "Psr\\Log\\": "src" - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Common interface for logging libraries", - "homepage": "https://github.com/php-fig/log", + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", "keywords": [ - "log", - "psr", - "psr-3" + "coverage", + "testing", + "xunit" ], "support": { - "source": "https://github.com/php-fig/log/tree/3.0.2" + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/14.2.3" }, - "time": "2024-09-11T13:17:53+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2026-07-06T15:04:02+00:00" }, { - "name": "react/cache", - "version": "v1.2.0", + "name": "phpunit/php-file-iterator", + "version": "7.0.0", "source": { "type": "git", - "url": "https://github.com/reactphp/cache.git", - "reference": "d47c472b64aa5608225f47965a484b75c7817d5b" + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "6e5aa1fb0a95b1703d83e721299ee18bb4e2de50" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b", - "reference": "d47c472b64aa5608225f47965a484b75c7817d5b", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/6e5aa1fb0a95b1703d83e721299ee18bb4e2de50", + "reference": "6e5aa1fb0a95b1703d83e721299ee18bb4e2de50", "shasum": "" }, "require": { - "php": ">=5.3.0", - "react/promise": "^3.0 || ^2.0 || ^1.1" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35" + "phpunit/phpunit": "^13.0" }, "type": "library", - "autoload": { - "psr-4": { - "React\\Cache\\": "src/" + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" } }, + "autoload": { + "classmap": [ + "src/" + ] + }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Christian Lück", - "email": "christian@clue.engineering", - "homepage": "https://clue.engineering/" - }, - { - "name": "Cees-Jan Kiewiet", - "email": "reactphp@ceesjankiewiet.nl", - "homepage": "https://wyrihaximus.net/" - }, - { - "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" - }, - { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Async, Promise-based cache interface for ReactPHP", + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", "keywords": [ - "cache", - "caching", - "promise", - "reactphp" + "filesystem", + "iterator" ], "support": { - "issues": "https://github.com/reactphp/cache/issues", - "source": "https://github.com/reactphp/cache/tree/v1.2.0" + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/7.0.0" }, "funding": [ { - "url": "https://opencollective.com/reactphp", - "type": "open_collective" - } - ], - "time": "2022-11-30T15:59:55+00:00" + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:33:26+00:00" }, { - "name": "react/child-process", - "version": "v0.6.7", + "name": "phpunit/php-invoker", + "version": "7.0.0", "source": { "type": "git", - "url": "https://github.com/reactphp/child-process.git", - "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3" + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/child-process/zipball/970f0e71945556422ee4570ccbabaedc3cf04ad3", - "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88", + "reference": "42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88", "shasum": "" }, "require": { - "evenement/evenement": "^3.0 || ^2.0 || ^1.0", - "php": ">=5.3.0", - "react/event-loop": "^1.2", - "react/stream": "^1.4" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", - "react/socket": "^1.16", - "sebastian/environment": "^5.0 || ^3.0 || ^2.0 || ^1.0" + "ext-pcntl": "*", + "phpunit/phpunit": "^13.0" + }, + "suggest": { + "ext-pcntl": "*" }, "type": "library", - "autoload": { - "psr-4": { - "React\\ChildProcess\\": "src/" + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" } }, + "autoload": { + "classmap": [ + "src/" + ] + }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Christian Lück", - "email": "christian@clue.engineering", - "homepage": "https://clue.engineering/" - }, - { - "name": "Cees-Jan Kiewiet", - "email": "reactphp@ceesjankiewiet.nl", - "homepage": "https://wyrihaximus.net/" - }, - { - "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" - }, - { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Event-driven library for executing child processes with ReactPHP.", + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", "keywords": [ - "event-driven", - "process", - "reactphp" + "process" ], "support": { - "issues": "https://github.com/reactphp/child-process/issues", - "source": "https://github.com/reactphp/child-process/tree/v0.6.7" + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/7.0.0" }, "funding": [ { - "url": "https://opencollective.com/reactphp", - "type": "open_collective" + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-invoker", + "type": "tidelift" } ], - "time": "2025-12-23T15:25:20+00:00" + "time": "2026-02-06T04:34:47+00:00" }, { - "name": "react/dns", - "version": "v1.14.0", + "name": "phpunit/php-text-template", + "version": "6.0.0", "source": { "type": "git", - "url": "https://github.com/reactphp/dns.git", - "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3" + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/dns/zipball/7562c05391f42701c1fccf189c8225fece1cd7c3", - "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/a47af19f93f76aa3368303d752aa5272ca3299f4", + "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4", "shasum": "" }, "require": { - "php": ">=5.3.0", - "react/cache": "^1.0 || ^0.6 || ^0.5", - "react/event-loop": "^1.2", - "react/promise": "^3.2 || ^2.7 || ^1.2.1" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", - "react/async": "^4.3 || ^3 || ^2", - "react/promise-timer": "^1.11" + "phpunit/phpunit": "^13.0" }, "type": "library", - "autoload": { - "psr-4": { - "React\\Dns\\": "src/" + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" } }, + "autoload": { + "classmap": [ + "src/" + ] + }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Christian Lück", - "email": "christian@clue.engineering", - "homepage": "https://clue.engineering/" - }, - { - "name": "Cees-Jan Kiewiet", - "email": "reactphp@ceesjankiewiet.nl", - "homepage": "https://wyrihaximus.net/" - }, - { - "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" - }, - { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Async DNS resolver for ReactPHP", + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", "keywords": [ - "async", - "dns", - "dns-resolver", - "reactphp" + "template" ], "support": { - "issues": "https://github.com/reactphp/dns/issues", - "source": "https://github.com/reactphp/dns/tree/v1.14.0" + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/6.0.0" }, "funding": [ { - "url": "https://opencollective.com/reactphp", - "type": "open_collective" + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-text-template", + "type": "tidelift" } ], - "time": "2025-11-18T19:34:28+00:00" + "time": "2026-02-06T04:36:37+00:00" }, { - "name": "react/event-loop", - "version": "v1.6.0", + "name": "phpunit/php-timer", + "version": "9.0.0", "source": { "type": "git", - "url": "https://github.com/reactphp/event-loop.git", - "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a" + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "a0e12065831f6ab0d83120dc61513eb8d9a966f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/event-loop/zipball/ba276bda6083df7e0050fd9b33f66ad7a4ac747a", - "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/a0e12065831f6ab0d83120dc61513eb8d9a966f6", + "reference": "a0e12065831f6ab0d83120dc61513eb8d9a966f6", "shasum": "" }, "require": { - "php": ">=5.3.0" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" - }, - "suggest": { - "ext-pcntl": "For signal handling support when using the StreamSelectLoop" + "phpunit/phpunit": "^13.0" }, "type": "library", - "autoload": { - "psr-4": { - "React\\EventLoop\\": "src/" + "extra": { + "branch-alias": { + "dev-main": "9.0-dev" } }, + "autoload": { + "classmap": [ + "src/" + ] + }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Christian Lück", - "email": "christian@clue.engineering", - "homepage": "https://clue.engineering/" - }, - { - "name": "Cees-Jan Kiewiet", - "email": "reactphp@ceesjankiewiet.nl", - "homepage": "https://wyrihaximus.net/" - }, - { - "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" - }, - { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", "keywords": [ - "asynchronous", - "event-loop" + "timer" ], "support": { - "issues": "https://github.com/reactphp/event-loop/issues", - "source": "https://github.com/reactphp/event-loop/tree/v1.6.0" + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/9.0.0" }, "funding": [ { - "url": "https://opencollective.com/reactphp", - "type": "open_collective" + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-timer", + "type": "tidelift" } ], - "time": "2025-11-17T20:46:25+00:00" + "time": "2026-02-06T04:37:53+00:00" }, { - "name": "react/promise", - "version": "v3.3.0", + "name": "phpunit/phpunit", + "version": "13.1.14", "source": { "type": "git", - "url": "https://github.com/reactphp/promise.git", - "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "cdd419c33c040c6b570e51dba8ecbe81d399da53" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", - "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/cdd419c33c040c6b570e51dba8ecbe81d399da53", + "reference": "cdd419c33c040c6b570e51dba8ecbe81d399da53", "shasum": "" }, "require": { - "php": ">=7.1.0" - }, - "require-dev": { - "phpstan/phpstan": "1.12.28 || 1.4.10", - "phpunit/phpunit": "^9.6 || ^7.5" + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.4.1", + "phpunit/php-code-coverage": "^14.1.10", + "phpunit/php-file-iterator": "^7.0.0", + "phpunit/php-invoker": "^7.0.0", + "phpunit/php-text-template": "^6.0.0", + "phpunit/php-timer": "^9.0.0", + "sebastian/cli-parser": "^5.0.0", + "sebastian/comparator": "^8.2.1", + "sebastian/diff": "^8.3.0", + "sebastian/environment": "^9.3.2", + "sebastian/exporter": "^8.1.0", + "sebastian/git-state": "^1.0", + "sebastian/global-state": "^9.0.1", + "sebastian/object-enumerator": "^8.0.0", + "sebastian/recursion-context": "^8.0.0", + "sebastian/type": "^7.0.1", + "sebastian/version": "^7.0.0", + "staabm/side-effects-detector": "^1.0.5" }, + "bin": [ + "phpunit" + ], "type": "library", + "extra": { + "branch-alias": { + "dev-main": "13.1-dev" + } + }, "autoload": { "files": [ - "src/functions_include.php" + "src/Framework/Assert/Functions.php" ], - "psr-4": { - "React\\Promise\\": "src/" - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" - }, + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/13.1.14" + }, + "funding": [ { - "name": "Christian Lück", + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-06-04T06:16:42+00:00" + }, + { + "name": "psr/cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/cache.git", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for caching libraries", + "keywords": [ + "cache", + "psr", + "psr-6" + ], + "support": { + "source": "https://github.com/php-fig/cache/tree/3.0.0" + }, + "time": "2021-02-03T23:26:27+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "react/cache", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/cache.git", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/promise": "^3.0 || ^2.0 || ^1.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", "email": "christian@clue.engineering", "homepage": "https://clue.engineering/" }, @@ -1605,212 +2011,1483 @@ "email": "reactphp@ceesjankiewiet.nl", "homepage": "https://wyrihaximus.net/" }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, { "name": "Chris Boden", "email": "cboden@gmail.com", "homepage": "https://cboden.dev/" } ], - "description": "A lightweight implementation of CommonJS Promises/A for PHP", + "description": "Async, Promise-based cache interface for ReactPHP", "keywords": [ + "cache", + "caching", "promise", - "promises" + "reactphp" ], "support": { - "issues": "https://github.com/reactphp/promise/issues", - "source": "https://github.com/reactphp/promise/tree/v3.3.0" + "issues": "https://github.com/reactphp/cache/issues", + "source": "https://github.com/reactphp/cache/tree/v1.2.0" }, "funding": [ { - "url": "https://opencollective.com/reactphp", - "type": "open_collective" + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2022-11-30T15:59:55+00:00" + }, + { + "name": "react/child-process", + "version": "v0.6.7", + "source": { + "type": "git", + "url": "https://github.com/reactphp/child-process.git", + "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/child-process/zipball/970f0e71945556422ee4570ccbabaedc3cf04ad3", + "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/event-loop": "^1.2", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/socket": "^1.16", + "sebastian/environment": "^5.0 || ^3.0 || ^2.0 || ^1.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\ChildProcess\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven library for executing child processes with ReactPHP.", + "keywords": [ + "event-driven", + "process", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/child-process/issues", + "source": "https://github.com/reactphp/child-process/tree/v0.6.7" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-12-23T15:25:20+00:00" + }, + { + "name": "react/dns", + "version": "v1.14.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/dns.git", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/dns/zipball/7562c05391f42701c1fccf189c8225fece1cd7c3", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/cache": "^1.0 || ^0.6 || ^0.5", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.7 || ^1.2.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3 || ^2", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Dns\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async DNS resolver for ReactPHP", + "keywords": [ + "async", + "dns", + "dns-resolver", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/dns/issues", + "source": "https://github.com/reactphp/dns/tree/v1.14.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-18T19:34:28+00:00" + }, + { + "name": "react/event-loop", + "version": "v1.6.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/event-loop.git", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/event-loop/zipball/ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "suggest": { + "ext-pcntl": "For signal handling support when using the StreamSelectLoop" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\EventLoop\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", + "keywords": [ + "asynchronous", + "event-loop" + ], + "support": { + "issues": "https://github.com/reactphp/event-loop/issues", + "source": "https://github.com/reactphp/event-loop/tree/v1.6.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-17T20:46:25+00:00" + }, + { + "name": "react/promise", + "version": "v3.3.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise.git", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpstan/phpstan": "1.12.28 || 1.4.10", + "phpunit/phpunit": "^9.6 || ^7.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "A lightweight implementation of CommonJS Promises/A for PHP", + "keywords": [ + "promise", + "promises" + ], + "support": { + "issues": "https://github.com/reactphp/promise/issues", + "source": "https://github.com/reactphp/promise/tree/v3.3.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-08-19T18:57:03+00:00" + }, + { + "name": "react/socket", + "version": "v1.17.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/socket.git", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/socket/zipball/ef5b17b81f6f60504c539313f94f2d826c5faa08", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/dns": "^1.13", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.6 || ^1.2.1", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3.3 || ^2", + "react/promise-stream": "^1.4", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Socket\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP", + "keywords": [ + "Connection", + "Socket", + "async", + "reactphp", + "stream" + ], + "support": { + "issues": "https://github.com/reactphp/socket/issues", + "source": "https://github.com/reactphp/socket/tree/v1.17.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-19T20:47:34+00:00" + }, + { + "name": "react/stream", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/stream.git", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.8", + "react/event-loop": "^1.2" + }, + "require-dev": { + "clue/stream-filter": "~1.2", + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Stream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP", + "keywords": [ + "event-driven", + "io", + "non-blocking", + "pipe", + "reactphp", + "readable", + "stream", + "writable" + ], + "support": { + "issues": "https://github.com/reactphp/stream/issues", + "source": "https://github.com/reactphp/stream/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-06-11T12:45:25+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "48a4654fa5e48c1c81214e9930048a572d4b23ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/48a4654fa5e48c1c81214e9930048a572d4b23ca", + "reference": "48a4654fa5e48c1c81214e9930048a572d4b23ca", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:39:44+00:00" + }, + { + "name": "sebastian/comparator", + "version": "8.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "ce999bf08b2c387a5423fe56961c32eed3f88089" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/ce999bf08b2c387a5423fe56961c32eed3f88089", + "reference": "ce999bf08b2c387a5423fe56961c32eed3f88089", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.4", + "sebastian/diff": "^8.3", + "sebastian/exporter": "^8.0.3" + }, + "require-dev": { + "phpunit/phpunit": "^13.1.10" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/8.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-05-21T04:46:40+00:00" + }, + { + "name": "sebastian/complexity", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "c5651c795c98093480df79350cb050813fc7a2f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/c5651c795c98093480df79350cb050813fc7a2f3", + "reference": "c5651c795c98093480df79350cb050813fc7a2f3", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/complexity", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:41:32+00:00" + }, + { + "name": "sebastian/diff", + "version": "8.3.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "b36d33b6e796513de7cb7df053afb3f55eefcd47" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b36d33b6e796513de7cb7df053afb3f55eefcd47", + "reference": "b36d33b6e796513de7cb7df053afb3f55eefcd47", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0", + "symfony/process": "^7.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/8.3.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/diff", + "type": "tidelift" + } + ], + "time": "2026-05-15T04:58:09+00:00" + }, + { + "name": "sebastian/environment", + "version": "9.3.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e", + "reference": "6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.1.11" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/9.3.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2026-05-25T13:41:38+00:00" + }, + { + "name": "sebastian/exporter", + "version": "8.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "cfaa77c750dcad6f44c9bac8f62ac486e1c82c26" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/cfaa77c750dcad6f44c9bac8f62ac486e1c82c26", + "reference": "cfaa77c750dcad6f44c9bac8f62ac486e1c82c26", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.4", + "sebastian/recursion-context": "^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^13.2.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/8.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2026-07-13T11:35:11+00:00" + }, + { + "name": "sebastian/git-state", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/git-state.git", + "reference": "792a952e0eba55b6960a48aeceb9f371aad1f76b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/git-state/zipball/792a952e0eba55b6960a48aeceb9f371aad1f76b", + "reference": "792a952e0eba55b6960a48aeceb9f371aad1f76b", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for describing the state of a Git checkout", + "homepage": "https://github.com/sebastianbergmann/git-state", + "support": { + "issues": "https://github.com/sebastianbergmann/git-state/issues", + "security": "https://github.com/sebastianbergmann/git-state/security/policy", + "source": "https://github.com/sebastianbergmann/git-state/tree/1.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/git-state", + "type": "tidelift" + } + ], + "time": "2026-03-21T12:54:28+00:00" + }, + { + "name": "sebastian/global-state", + "version": "9.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "ba68ba79da690cf7eddefd3ce5b78b20b9ba9945" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/ba68ba79da690cf7eddefd3ce5b78b20b9ba9945", + "reference": "ba68ba79da690cf7eddefd3ce5b78b20b9ba9945", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "sebastian/object-reflector": "^6.0", + "sebastian/recursion-context": "^8.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^13.1.13" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/9.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "type": "tidelift" + } + ], + "time": "2026-06-01T15:11:33+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "5.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d1b6f8fce682505dbd048977f1abedf1b8ad3ff8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d1b6f8fce682505dbd048977f1abedf1b8ad3ff8", + "reference": "d1b6f8fce682505dbd048977f1abedf1b8ad3ff8", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.8.0", + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.2.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/5.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code", + "type": "tidelift" + } + ], + "time": "2026-07-09T08:42:34+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "8.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "b39ab125fd9a7434b0ecbc4202eebce11a98cfc5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/b39ab125fd9a7434b0ecbc4202eebce11a98cfc5", + "reference": "b39ab125fd9a7434b0ecbc4202eebce11a98cfc5", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "sebastian/object-reflector": "^6.0", + "sebastian/recursion-context": "^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/8.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/object-enumerator", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:46:36+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "3ca042c2c60b0eab094f8a1b6a7093f4d4c72200" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/3ca042c2c60b0eab094f8a1b6a7093f4d4c72200", + "reference": "3ca042c2c60b0eab094f8a1b6a7093f4d4c72200", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/object-reflector", + "type": "tidelift" } ], - "time": "2025-08-19T18:57:03+00:00" + "time": "2026-02-06T04:47:13+00:00" }, { - "name": "react/socket", - "version": "v1.17.0", + "name": "sebastian/recursion-context", + "version": "8.0.0", "source": { "type": "git", - "url": "https://github.com/reactphp/socket.git", - "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08" + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "74c5af21f6a5833e91767ca068c4d3dfec15317e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/socket/zipball/ef5b17b81f6f60504c539313f94f2d826c5faa08", - "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/74c5af21f6a5833e91767ca068c4d3dfec15317e", + "reference": "74c5af21f6a5833e91767ca068c4d3dfec15317e", "shasum": "" }, "require": { - "evenement/evenement": "^3.0 || ^2.0 || ^1.0", - "php": ">=5.3.0", - "react/dns": "^1.13", - "react/event-loop": "^1.2", - "react/promise": "^3.2 || ^2.6 || ^1.2.1", - "react/stream": "^1.4" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", - "react/async": "^4.3 || ^3.3 || ^2", - "react/promise-stream": "^1.4", - "react/promise-timer": "^1.11" + "phpunit/phpunit": "^13.0" }, "type": "library", - "autoload": { - "psr-4": { - "React\\Socket\\": "src/" + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" } }, + "autoload": { + "classmap": [ + "src/" + ] + }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Christian Lück", - "email": "christian@clue.engineering", - "homepage": "https://clue.engineering/" - }, - { - "name": "Cees-Jan Kiewiet", - "email": "reactphp@ceesjankiewiet.nl", - "homepage": "https://wyrihaximus.net/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" }, { - "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" }, { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" + "name": "Adam Harvey", + "email": "aharvey@php.net" } ], - "description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP", - "keywords": [ - "Connection", - "Socket", - "async", - "reactphp", - "stream" - ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", "support": { - "issues": "https://github.com/reactphp/socket/issues", - "source": "https://github.com/reactphp/socket/tree/v1.17.0" + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/8.0.0" }, "funding": [ { - "url": "https://opencollective.com/reactphp", - "type": "open_collective" + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" } ], - "time": "2025-11-19T20:47:34+00:00" + "time": "2026-02-06T04:51:28+00:00" }, { - "name": "react/stream", - "version": "v1.4.0", + "name": "sebastian/type", + "version": "7.0.1", "source": { "type": "git", - "url": "https://github.com/reactphp/stream.git", - "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d" + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "fee0309275847fefd7636167085e379c1dbf6990" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d", - "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/fee0309275847fefd7636167085e379c1dbf6990", + "reference": "fee0309275847fefd7636167085e379c1dbf6990", "shasum": "" }, "require": { - "evenement/evenement": "^3.0 || ^2.0 || ^1.0", - "php": ">=5.3.8", - "react/event-loop": "^1.2" + "php": ">=8.4" }, "require-dev": { - "clue/stream-filter": "~1.2", - "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + "phpunit/phpunit": "^13.1.10" }, "type": "library", - "autoload": { - "psr-4": { - "React\\Stream\\": "src/" + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" } }, + "autoload": { + "classmap": [ + "src/" + ] + }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Christian Lück", - "email": "christian@clue.engineering", - "homepage": "https://clue.engineering/" - }, - { - "name": "Cees-Jan Kiewiet", - "email": "reactphp@ceesjankiewiet.nl", - "homepage": "https://wyrihaximus.net/" - }, - { - "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" - }, - { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP", - "keywords": [ - "event-driven", - "io", - "non-blocking", - "pipe", - "reactphp", - "readable", - "stream", - "writable" - ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", "support": { - "issues": "https://github.com/reactphp/stream/issues", - "source": "https://github.com/reactphp/stream/tree/v1.4.0" + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/7.0.1" }, "funding": [ { - "url": "https://opencollective.com/reactphp", - "type": "open_collective" + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" } ], - "time": "2024-06-11T12:45:25+00:00" + "time": "2026-05-20T06:49:11+00:00" }, { - "name": "sebastian/diff", - "version": "8.3.0", + "name": "sebastian/version", + "version": "7.0.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "b36d33b6e796513de7cb7df053afb3f55eefcd47" + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b36d33b6e796513de7cb7df053afb3f55eefcd47", - "reference": "b36d33b6e796513de7cb7df053afb3f55eefcd47", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/ad37a5552c8e2b88572249fdc19b6da7792e021b", + "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b", "shasum": "" }, "require": { "php": ">=8.4" }, - "require-dev": { - "phpunit/phpunit": "^13.0", - "symfony/process": "^7.2" - }, "type": "library", "extra": { "branch-alias": { - "dev-main": "8.3-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -1825,25 +3502,16 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - }, - { - "name": "Kore Nordmann", - "email": "mail@kore-nordmann.de" + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Diff implementation", - "homepage": "https://github.com/sebastianbergmann/diff", - "keywords": [ - "diff", - "udiff", - "unidiff", - "unified diff" - ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", "support": { - "issues": "https://github.com/sebastianbergmann/diff/issues", - "security": "https://github.com/sebastianbergmann/diff/security/policy", - "source": "https://github.com/sebastianbergmann/diff/tree/8.3.0" + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/7.0.0" }, "funding": [ { @@ -1859,11 +3527,11 @@ "type": "thanks_dev" }, { - "url": "https://tidelift.com/funding/github/packagist/sebastian/diff", + "url": "https://tidelift.com/funding/github/packagist/sebastian/version", "type": "tidelift" } ], - "time": "2026-05-15T04:58:09+00:00" + "time": "2026-02-06T04:52:52+00:00" }, { "name": "simplemachines/build-tools", @@ -1891,6 +3559,58 @@ }, "time": "2026-05-27T23:44:16+00:00" }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, { "name": "symfony/cache", "version": "v6.4.41", @@ -3572,6 +5292,56 @@ } ], "time": "2026-05-25T06:03:23+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^8.1" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/2.0.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-12-08T11:19:18+00:00" } ], "aliases": [], diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 00000000000..08c514aee2d --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,20 @@ + + + + + tests/Unit + + + + + Sources + + + diff --git a/tests/Unit/ActionTraitTest.php b/tests/Unit/ActionTraitTest.php new file mode 100644 index 00000000000..a0b63d21518 --- /dev/null +++ b/tests/Unit/ActionTraitTest.php @@ -0,0 +1,58 @@ +assertInstanceOf(Login2::class, Login2::load()); + $this->assertInstanceOf(Logout::class, Logout::load()); + } + + public function testLoadIsStillCorrectWhenTheParentWasLoadedFirst(): void + { + // $obj is a static property declared in the trait, so it is shared with + // every descendant that does not redeclare it. Loading the parent first + // used to leave the parent's instance in the slot the child reads. + Login2::load(); + + $this->assertInstanceOf(Logout::class, Logout::load()); + $this->assertInstanceOf(Login::class, Login::load()); + } + + public function testLoadIsStillCorrectWhenTheChildWasLoadedFirst(): void + { + Logout::load(); + + $this->assertInstanceOf(Login2::class, Login2::load()); + } + + public function testTheSameProblemInAnUnrelatedHierarchy(): void + { + Notify::load(); + + $this->assertInstanceOf(NotifyBoard::class, NotifyBoard::load()); + } + + public function testLoadCachesTheInstanceItReturns(): void + { + $this->assertSame(Login2::load(), Login2::load()); + } +} diff --git a/tests/Unit/CreatePostNotifyTest.php b/tests/Unit/CreatePostNotifyTest.php new file mode 100644 index 00000000000..8bdf1a515ad --- /dev/null +++ b/tests/Unit/CreatePostNotifyTest.php @@ -0,0 +1,80 @@ +assertSame(3.0, $this->getTimeOffset('Etc/GMT-5')); + } + + // Note: the section banner above must not be the first thing in this group when + // the first member carries an attribute. The SMF/section_comments fixer inserts + // the banner between the attribute and its method, which is why the data provider + // case is second rather than first. + #[DataProvider('timezoneProvider')] + public function testGetTimeOffset(string $timezone, float $expected): void + { + $this->assertSame($expected, $this->getTimeOffset($timezone)); + } + + /*********************** + * Public static methods + ***********************/ + + /** + * @return array + */ + public static function timezoneProvider(): array + { + return [ + 'UTC is no offset' => ['UTC', 0.0], + 'whole hour' => ['Etc/GMT-5', 5.0], + 'negative whole hour' => ['Etc/GMT+5', -5.0], + 'half hour is not truncated' => ['Asia/Kolkata', 5.5], + 'quarter hour is not truncated' => ['Asia/Kathmandu', 5.75], + 'empty time zone falls back to zero' => ['', 0.0], + ]; + } + + /****************** + * Internal methods + ******************/ + + protected function setUp(): void + { + // The offset is relative to the forum's own time zone, so pin it. + Config::$modSettings['default_timezone'] = 'UTC'; + } + + protected function tearDown(): void + { + unset(Config::$modSettings['default_timezone']); + } + + /** + * Calls the protected helper under test. + */ + private function getTimeOffset(string $timezone): float + { + $method = new \ReflectionMethod(CreatePost_Notify::class, 'getTimeOffset'); + + return $method->invoke(null, $timezone); + } +} diff --git a/tests/Unit/UtilsTest.php b/tests/Unit/UtilsTest.php new file mode 100644 index 00000000000..fddf008068d --- /dev/null +++ b/tests/Unit/UtilsTest.php @@ -0,0 +1,47 @@ +assertSame('(?>ab(?>c|d))', Utils::buildRegex(['abc', 'abd'])); + } + + public function testBuildRegexMatchesEveryStringItWasBuiltFrom(): void + { + $strings = ['abc', 'abd', 'xyz', 'a.b', 'a+b', 'a(b)']; + $regex = Utils::buildRegex($strings); + + foreach ($strings as $string) { + $this->assertMatchesRegularExpression('~^' . $regex . '$~', $string); + } + } + + public function testBuildRegexQuotesTrailingSpecialCharacters(): void + { + // A trailing character that is special in a regex must stay quoted, or the + // resulting pattern matches things it should not. + $regex = Utils::buildRegex(['ab.', 'ab']); + + $this->assertMatchesRegularExpression('~^' . $regex . '$~', 'ab.'); + $this->assertDoesNotMatchRegularExpression('~^' . $regex . '$~', 'abx'); + } + + public function testBuildRegexHandlesASingleString(): void + { + $this->assertMatchesRegularExpression('~^' . Utils::buildRegex(['solo']) . '$~', 'solo'); + } +} diff --git a/tests/Unit/index.php b/tests/Unit/index.php new file mode 100644 index 00000000000..2844a3b9e7b --- /dev/null +++ b/tests/Unit/index.php @@ -0,0 +1,8 @@ +setPsr4('SMF\\', TESTS_BOARDDIR . '/Sources'); +$loader->setPsr4('SMF\\Themes\\', TESTS_BOARDDIR . '/Themes'); diff --git a/tests/index.php b/tests/index.php new file mode 100644 index 00000000000..2844a3b9e7b --- /dev/null +++ b/tests/index.php @@ -0,0 +1,8 @@ + Date: Wed, 29 Jul 2026 20:43:18 +0200 Subject: [PATCH 04/26] Runs the unit tests in CI and documents them Adds a PHPUnit workflow across the same 8.4 and 8.5 matrix the syntax check already uses, and a composer test script. Two adjustments fall out of running the suite next to the existing checks. The PHPUnit cache lives in .phpunit.cache rather than under cache/, because check-smf-index walks every directory that is not hidden and would otherwise report a missing index file the moment anyone runs the tests locally. And AGENTS.md no longer says there is no test suite; it now says what the suite does and does not cover, so an agent does not mistake a green run for proof that a change works. Co-Authored-By: Claude Opus 5 --- .github/workflows/phpunit.yml | 40 ++++++++++++++++++++++++++++++++++ .gitignore | 4 ++++ AGENTS.md | 19 ++++++++++++---- composer.json | 3 ++- phpunit.xml.dist | 2 +- tests/Unit/ActionTraitTest.php | 16 ++++++++++---- 6 files changed, 74 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/phpunit.yml diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml new file mode 100644 index 00000000000..398c9f9ae16 --- /dev/null +++ b/.github/workflows/phpunit.yml @@ -0,0 +1,40 @@ +name: PHPUnit + +on: + push: + branches: + - release-3.0 + pull_request: + +jobs: + phpunit: + name: Unit tests + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: [ 8.4, 8.5 ] + + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #4.2.2 + + - name: Setup PHP ${{ matrix.php }} + uses: shivammathur/setup-php@9e72090525849c5e82e596468b86eb55e9cc5401 #2.32.0 + with: + php-version: ${{ matrix.php }} + coverage: none + + - name: Cache Composer packages + id: composer-cache + uses: actions/cache@d4323d4df104b026a6aa633fdb11d772146be0bf #4.2.2 + with: + path: vendor + key: ${{ runner.os }}-php${{ matrix.php }}-${{ hashFiles('**/composer.lock') }} + restore-keys: ${{ runner.os }}-php${{ matrix.php }}- + + - name: Install dependencies + if: steps.composer-cache.outputs.cache-hit != 'true' + run: composer install --prefer-dist --no-progress --ansi + + - name: Run the unit tests + run: vendor/bin/phpunit --no-coverage --colors=always diff --git a/.gitignore b/.gitignore index 8a574e9b8d5..1dfe550f483 100644 --- a/.gitignore +++ b/.gitignore @@ -87,3 +87,7 @@ vendor/ .phplint-cache .phplint.cache composer.phar + +# PHPUnit +.phpunit.cache/ +.phpunit.result.cache diff --git a/AGENTS.md b/AGENTS.md index 052a20bd56c..7b039ded862 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,11 +79,22 @@ param, throws, return. ## Verifying a change -**There is no test suite.** No PHPUnit, no `tests/` directory, nothing in the history. -CI only proves that the code parses (`phplint` on 8.4 and 8.5) and is formatted -correctly. It never executes SMF. Do not assume green checks mean a change works. +There is a unit test suite, but it is small and deliberately narrow: -So verify by running the forum. The repository ships a Docker environment: +```bash +composer test # or: vendor/bin/phpunit +``` + +It runs without a database, a `Settings.php` or a request: `tests/bootstrap.php` only +defines the constants `index.php` would define and points the autoloader at `Sources/`. +That covers pure helpers and class-level behaviour. **Anything reaching +`Config::$modSettings`, `User::$me` or `Db::$db` is out of scope**, which is most of the +forum. Add a test there when the code you are touching is reachable that way; do not +contort production code to make it testable. + +The rest of CI only proves the code parses (`phplint` on 8.4 and 8.5) and is formatted. +So a fully green PR still tells you very little about whether a change works. Verify by +running the forum. The repository ships a Docker environment: ```bash docker compose up -d --build diff --git a/composer.json b/composer.json index 83b76bad667..e6585abe13e 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,8 @@ "phpunit/phpunit": "^13.1" }, "scripts": { - "lint": "php-cs-fixer --quiet check --config .php-cs-fixer.dist.php --path-mode=intersection $(git diff --name-only \"*.php\") --allow-risky=yes || php-cs-fixer check --diff --config .php-cs-fixer.dist.php --path-mode=intersection $(git diff --name-only \"*.php\") --allow-risky=yes", + "test": "phpunit --no-coverage", + "lint":"php-cs-fixer --quiet check --config .php-cs-fixer.dist.php --path-mode=intersection $(git diff --name-only \"*.php\") --allow-risky=yes || php-cs-fixer check --diff --config .php-cs-fixer.dist.php --path-mode=intersection $(git diff --name-only \"*.php\") --allow-risky=yes", "lint-fix": "php-cs-fixer fix -v --config .php-cs-fixer.dist.php --path-mode=intersection $(git diff --name-only \"*.php\") --allow-risky=yes", "post-install-cmd": "php ./vendor/simplemachines/build-tools/secure-vendor-dir.php", "post-update-cmd": "php ./vendor/simplemachines/build-tools/secure-vendor-dir.php" diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 08c514aee2d..951db14dd10 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -3,7 +3,7 @@ xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd" bootstrap="tests/bootstrap.php" colors="true" - cacheDirectory="cache/.phpunit.cache" + cacheDirectory=".phpunit.cache" beStrictAboutOutputDuringTests="true" failOnRisky="true" failOnWarning="true"> diff --git a/tests/Unit/ActionTraitTest.php b/tests/Unit/ActionTraitTest.php index a0b63d21518..e62160fd57a 100644 --- a/tests/Unit/ActionTraitTest.php +++ b/tests/Unit/ActionTraitTest.php @@ -6,11 +6,13 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +use SMF\Actions\Agreement; +use SMF\Actions\AgreementAccept; use SMF\Actions\Login; use SMF\Actions\Login2; use SMF\Actions\Logout; -use SMF\Actions\Notify; -use SMF\Actions\NotifyBoard; +use SMF\Actions\Unread; +use SMF\Actions\UnreadReplies; use SMF\ActionTrait; #[CoversClass(ActionTrait::class)] @@ -46,9 +48,15 @@ public function testLoadIsStillCorrectWhenTheChildWasLoadedFirst(): void public function testTheSameProblemInAnUnrelatedHierarchy(): void { - Notify::load(); + // Eleven action classes extend another action and none redeclare $obj, + // so this is not specific to the login hierarchy. Notify is abstract and + // so cannot be loaded at all; Agreement and Unread are the other pairs + // with a concrete parent. + Agreement::load(); + Unread::load(); - $this->assertInstanceOf(NotifyBoard::class, NotifyBoard::load()); + $this->assertInstanceOf(AgreementAccept::class, AgreementAccept::load()); + $this->assertInstanceOf(UnreadReplies::class, UnreadReplies::load()); } public function testLoadCachesTheInstanceItReturns(): void From 99ee1048965bc55f227a311a987fec1294e30088 Mon Sep 17 00:00:00 2001 From: albertlast Date: Wed, 29 Jul 2026 21:02:34 +0200 Subject: [PATCH 05/26] Broadens the unit tests to the rest of the stateless surface The first pass covered only the three things that had recently broken. Quite a lot more is reachable without a database once the bootstrap sets the paths and default language that the Unicode and entity helpers use to find their data files, which is six lines and still reads nothing from Settings.php. Adds coverage for Utils' entity-aware string handling and Unicode case conversion, IP, Url, Uuid, Sapi, Security's password hashing, Punycode and TimeInterval. 99 tests, 144 assertions, on 8.4 and 8.5. Two behaviours are deliberately described rather than asserted, because pinning them down would preserve something that looks wrong: - Sapi::memoryReturnBytes() strips the last character before parsing, so a unit-less value such as '128' reads as 12 and the '-1' that means "no limit" reads as 0. Only suffixed values are asserted. - Url::isScheme() compares the scheme without normalising case, so an uppercase scheme fails to match its own name. Only exact-case matching is asserted. IP's constructor accepts the packed binary form, which it cannot tell apart from any other 4 or 16 byte string, so 'nope' becomes 110.111.112.101. That one is genuine ambiguity rather than a defect, so it is pinned down as a test in its own right. Co-Authored-By: Claude Opus 5 --- tests/Unit/IPTest.php | 93 ++++++++++++++++++++++ tests/Unit/PunycodeTest.php | 47 +++++++++++ tests/Unit/SapiTest.php | 59 ++++++++++++++ tests/Unit/SecurityTest.php | 77 ++++++++++++++++++ tests/Unit/TimeIntervalTest.php | 60 ++++++++++++++ tests/Unit/UrlTest.php | 110 +++++++++++++++++++++++++ tests/Unit/UtilsTest.php | 137 ++++++++++++++++++++++++++++++++ tests/Unit/UuidTest.php | 107 +++++++++++++++++++++++++ tests/bootstrap.php | 13 +++ 9 files changed, 703 insertions(+) create mode 100644 tests/Unit/IPTest.php create mode 100644 tests/Unit/PunycodeTest.php create mode 100644 tests/Unit/SapiTest.php create mode 100644 tests/Unit/SecurityTest.php create mode 100644 tests/Unit/TimeIntervalTest.php create mode 100644 tests/Unit/UrlTest.php create mode 100644 tests/Unit/UuidTest.php diff --git a/tests/Unit/IPTest.php b/tests/Unit/IPTest.php new file mode 100644 index 00000000000..0efe911f33d --- /dev/null +++ b/tests/Unit/IPTest.php @@ -0,0 +1,93 @@ +assertSame('2001:db8::1', (string) new IP('2001:DB8::0001')); + } + + public function testItKeepsIPv4MappedAddressesIntact(): void + { + $this->assertSame('::ffff:1.2.3.4', (string) new IP('::ffff:1.2.3.4')); + } + + public function testFlagsNarrowValidationToOneFamily(): void + { + $this->assertTrue((new IP('1.2.3.4'))->isValid(FILTER_FLAG_IPV4)); + $this->assertFalse((new IP('1.2.3.4'))->isValid(FILTER_FLAG_IPV6)); + $this->assertTrue((new IP('2001:db8::1'))->isValid(FILTER_FLAG_IPV6)); + } + + public function testBinaryAndHexRoundTrip(): void + { + $ip = new IP('1.2.3.4'); + + $this->assertSame('01020304', $ip->toHex()); + $this->assertSame(4, \strlen((string) $ip->toBinary())); + $this->assertSame('1.2.3.4', (string) new IP((string) $ip->toBinary())); + } + + public function testAnEmptyOrUnparseableValueIsNotValid(): void + { + $this->assertFalse((new IP(''))->isValid()); + $this->assertFalse((new IP('abcde'))->isValid()); + $this->assertFalse((new IP('999.999.999.999'))->isValid()); + } + + public function testAnyFourByteStringIsReadAsAPackedAddress(): void + { + // The constructor accepts the packed binary form, and it cannot tell that + // apart from a four character string. This is a sharp edge worth pinning + // down: 'nope' is not rejected, it becomes an address. + $this->assertSame('110.111.112.101', (string) new IP('nope')); + $this->assertTrue((new IP('nope'))->isValid()); + + // The same applies at 16 bytes, where it becomes an IPv6 address. + $this->assertTrue((new IP('not an ip at all'))->isValid()); + } + + #[DataProvider('validityProvider')] + public function testValidity(string $input, bool $expected): void + { + $this->assertSame($expected, (new IP($input))->isValid()); + } + + /*********************** + * Public static methods + ***********************/ + + /** + * @return array + */ + public static function validityProvider(): array + { + return [ + 'ipv4' => ['192.168.0.1', true], + 'ipv4 broadcast' => ['255.255.255.255', true], + 'ipv6' => ['2001:db8::1', true], + 'ipv6 loopback' => ['::1', true], + 'octet out of range' => ['256.1.1.1', false], + 'too few octets' => ['1.2.3', false], + 'empty' => ['', false], + // Any 4 or 16 byte string is read as a packed address instead, so a + // rubbish value only fails validation at some other length. See + // testAnyFourByteStringIsReadAsAPackedAddress(). + 'words' => ['not an ip address at all', false], + ]; + } +} diff --git a/tests/Unit/PunycodeTest.php b/tests/Unit/PunycodeTest.php new file mode 100644 index 00000000000..f2a44376423 --- /dev/null +++ b/tests/Unit/PunycodeTest.php @@ -0,0 +1,47 @@ +assertSame('example.com', (new Punycode())->encode('example.com')); + } + + #[DataProvider('domainProvider')] + public function testEncodeAndDecodeAreInverses(string $unicode, string $ascii): void + { + $punycode = new Punycode(); + + $this->assertSame($ascii, $punycode->encode($unicode)); + $this->assertSame($unicode, $punycode->decode($ascii)); + } + + /*********************** + * Public static methods + ***********************/ + + /** + * @return array + */ + public static function domainProvider(): array + { + return [ + 'german umlaut' => ['münchen.de', 'xn--mnchen-3ya.de'], + 'multiple labels' => ['münchen.beispiel.de', 'xn--mnchen-3ya.beispiel.de'], + ]; + } +} diff --git a/tests/Unit/SapiTest.php b/tests/Unit/SapiTest.php new file mode 100644 index 00000000000..10908a726a9 --- /dev/null +++ b/tests/Unit/SapiTest.php @@ -0,0 +1,59 @@ +assertSame('/a/c', Sapi::canonicalPath('/a/./b/../c', false, false)); + $this->assertSame('/a', Sapi::canonicalPath('/a/b/..', false, false)); + } + + public function testTheSuiteRunsOnTheCommandLine(): void + { + $this->assertTrue(Sapi::isCLI()); + } + + #[DataProvider('memorySizeProvider')] + public function testMemoryReturnBytesUnderstandsUnitSuffixes(string $val, int $expected): void + { + $this->assertSame($expected, Sapi::memoryReturnBytes($val)); + } + + /*********************** + * Public static methods + ***********************/ + + /** + * Only values carrying a unit suffix are covered here. memoryReturnBytes() + * unconditionally strips the last character before parsing the number, so a + * plain byte count such as '128' or the '-1' that means "no limit" is not + * read correctly. Those cases are deliberately not asserted rather than + * pinned to the current behaviour. + * + * @return array + */ + public static function memorySizeProvider(): array + { + return [ + 'kilobytes' => ['512K', 524288], + 'megabytes' => ['256M', 268435456], + 'gigabytes' => ['1G', 1073741824], + 'lowercase suffix' => ['256m', 268435456], + 'zero megabytes' => ['0M', 0], + ]; + } +} diff --git a/tests/Unit/SecurityTest.php b/tests/Unit/SecurityTest.php new file mode 100644 index 00000000000..a1b8d401c0b --- /dev/null +++ b/tests/Unit/SecurityTest.php @@ -0,0 +1,77 @@ +assertTrue(Security::hashVerifyPassword('correct horse battery staple', $hash)); + } + + public function testAHashDoesNotVerifyAgainstAnythingElse(): void + { + $hash = Security::hashPassword('correct horse battery staple', self::COST); + + $this->assertFalse(Security::hashVerifyPassword('Correct horse battery staple', $hash)); + $this->assertFalse(Security::hashVerifyPassword('', $hash)); + } + + public function testHashingIsSaltedSoTheSamePasswordHashesDifferently(): void + { + $this->assertNotSame( + Security::hashPassword('same', self::COST), + Security::hashPassword('same', self::COST), + ); + } + + public function testHashesAreBcrypt(): void + { + $this->assertStringStartsWith('$2y$', Security::hashPassword('x', self::COST)); + } + + public function testTheCostFactorIsHonoured(): void + { + $this->assertStringStartsWith('$2y$04$', Security::hashPassword('x', 4)); + $this->assertStringStartsWith('$2y$05$', Security::hashPassword('x', 5)); + } + + public function testGeneratedPasswordsAreDistinctAndNonTrivial(): void + { + $first = Security::generatePassword(); + + $this->assertSame(20, \strlen($first)); + $this->assertNotSame($first, Security::generatePassword()); + } + + public function testGeneratedValidationCodesAreDistinctAndNonTrivial(): void + { + $first = Security::generateValidationCode(); + + $this->assertSame(10, \strlen($first)); + $this->assertNotSame($first, Security::generateValidationCode()); + } +} diff --git a/tests/Unit/TimeIntervalTest.php b/tests/Unit/TimeIntervalTest.php new file mode 100644 index 00000000000..424e3607b07 --- /dev/null +++ b/tests/Unit/TimeIntervalTest.php @@ -0,0 +1,60 @@ +assertSame('P1Y2M3DT4H5M6S', (string) new TimeInterval('P1Y2M3DT4H5M6S')); + } + + public function testTimeOnlyDurationsKeepTheirTimeDesignator(): void + { + $this->assertSame('PT30M', (string) new TimeInterval('PT30M')); + } + + public function testItCanBeBuiltFromAPlainDateInterval(): void + { + $this->assertSame( + 'P1D', + (string) TimeInterval::createFromDateInterval(new \DateInterval('P1D')), + ); + } + + public function testToSecondsIsMeasuredFromAGivenMoment(): void + { + $this->assertSame(3600, (new TimeInterval('PT1H'))->toSeconds(new \DateTimeImmutable('@0'))); + } + + public function testToSecondsDependsOnTheMomentForCalendarUnits(): void + { + // A month is not a fixed number of seconds. January is longer than + // February, and asking from a different starting point proves the + // interval is resolved against a real calendar rather than an average. + $january = (new TimeInterval('P1M'))->toSeconds(new \DateTimeImmutable('2026-01-01T00:00:00Z')); + $february = (new TimeInterval('P1M'))->toSeconds(new \DateTimeImmutable('2026-02-01T00:00:00Z')); + + $this->assertSame(31 * 86400, $january); + $this->assertSame(28 * 86400, $february); + } + + public function testToParsableSpellsTheDurationOut(): void + { + $this->assertSame( + '1 years 2 months 3 days 4 hours 5 minutes 6 seconds', + (new TimeInterval('P1Y2M3DT4H5M6S'))->toParsable(), + ); + } +} diff --git a/tests/Unit/UrlTest.php b/tests/Unit/UrlTest.php new file mode 100644 index 00000000000..302aeff039b --- /dev/null +++ b/tests/Unit/UrlTest.php @@ -0,0 +1,110 @@ +assertSame('a.example.com', $url->host); + $this->assertSame('/a/b', $url->path); + $this->assertSame('c=d', $url->query); + $this->assertSame('f', $url->fragment); + $this->assertSame(8080, $url->port); + } + + public function testMissingComponentsAreNotSet(): void + { + $url = new Url('https://example.com'); + + $this->assertFalse(isset($url->query)); + $this->assertFalse(isset($url->fragment)); + } + + public function testCastingBackToStringPreservesTheUrl(): void + { + $original = 'https://example.com/a/b?c=d#f'; + + $this->assertSame($original, (string) new Url($original)); + } + + public function testToAsciiPunycodesAnInternationalisedHost(): void + { + $this->assertSame( + 'https://xn--mnchen-3ya.de/', + (string) (new Url('https://münchen.de/'))->toAscii(), + ); + } + + public function testToAsciiPercentEncodesANonAsciiPath(): void + { + $this->assertSame( + 'https://xn--mnchen-3ya.de/stra%C3%9Fe', + (string) (new Url('https://münchen.de/straße'))->toAscii(), + ); + } + + public function testToUtf8ReversesPunycode(): void + { + $this->assertSame( + 'münchen.de', + (new Url('https://xn--mnchen-3ya.de/'))->toUtf8()->host, + ); + } + + public function testTheSchemeIsReportedExactlyAsItWasWritten(): void + { + // Schemes are case insensitive, but this does not normalise them, so a + // caller comparing against 'https' must lowercase first. + $this->assertSame('HTTPS', (new Url('HTTPS://example.com'))->scheme); + } + + public function testIsSchemeMatchesTheSchemeAsWritten(): void + { + $this->assertTrue((new Url('https://example.com'))->isScheme('https')); + $this->assertTrue((new Url('https://example.com'))->isScheme(['http', 'https'])); + $this->assertFalse((new Url('https://example.com'))->isScheme('ftp')); + + // Not asserted here: an uppercase scheme does not match its lowercase + // name, because isScheme() compares the un-normalised scheme with + // in_array(). RFC 3986 makes schemes case insensitive, so that looks + // like a defect rather than something to pin down in a test. + } + + #[DataProvider('validityProvider')] + public function testValidity(string $input, bool $expected): void + { + $this->assertSame($expected, (new Url($input))->isValid()); + } + + /*********************** + * Public static methods + ***********************/ + + /** + * @return array + */ + public static function validityProvider(): array + { + return [ + 'https' => ['https://example.com', true], + 'http with path' => ['http://example.com/a/b', true], + 'bare word' => ['notaurl', false], + 'empty' => ['', false], + ]; + } +} diff --git a/tests/Unit/UtilsTest.php b/tests/Unit/UtilsTest.php index fddf008068d..ffc139b707d 100644 --- a/tests/Unit/UtilsTest.php +++ b/tests/Unit/UtilsTest.php @@ -5,6 +5,7 @@ namespace SMF\Tests\Unit; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use SMF\Utils; @@ -44,4 +45,140 @@ public function testBuildRegexHandlesASingleString(): void { $this->assertMatchesRegularExpression('~^' . Utils::buildRegex(['solo']) . '$~', 'solo'); } + + public function testEntityAwareLengthCountsAnEntityAsOneCharacter(): void + { + $this->assertSame(3, Utils::entityStrlen('a&b')); + $this->assertSame(4, Utils::entityStrlen('déjà')); + } + + public function testEntityAwareSubstrDoesNotSplitAnEntity(): void + { + $this->assertSame('a&', Utils::entitySubstr('a&bc', 0, 2)); + } + + public function testEntityAwareStrposCountsEntitiesAsOne(): void + { + $this->assertSame(2, Utils::entityStrpos('a&bc', 'b')); + } + + public function testEntityAwareSplitKeepsEntitiesWhole(): void + { + $this->assertSame(['a', '&', 'b'], Utils::entityStrSplit('a&b')); + } + + public function testHtmlTrimRemovesEntityWhitespaceAtBothEnds(): void + { + $this->assertSame('a', Utils::htmlTrim('   a   ')); + $this->assertSame('a', Utils::htmlTrimLeft('  a')); + $this->assertSame('a', Utils::htmlTrimRight('a  ')); + } + + public function testTruncateRefusesToCutAnEntityInHalf(): void + { + $this->assertSame('abcde', Utils::truncate('abcdefghij', 5)); + + // '&' would not fit in the remaining budget, so it is dropped whole + // rather than emitted as a broken fragment. + $this->assertSame('a', Utils::truncate('a&bcdef', 5)); + } + + public function testShortenAppendsAnEllipsisOnlyWhenItShortens(): void + { + $this->assertSame('abcde...', Utils::shorten('abcdefghij', 5)); + $this->assertSame('abc', Utils::shorten('abc', 5)); + } + + public function testNormalizeComposesAndDecomposes(): void + { + $this->assertSame("\u{00E1}", Utils::normalize("a\u{0301}", 'c')); + $this->assertSame(2, mb_strlen(Utils::normalize("\u{00E1}", 'd'))); + } + + public function testConvertCaseHandlesCharactersWithNoSimpleMapping(): void + { + // Uppercasing the sharp s expands it to two characters, which a naive + // strtoupper() on bytes cannot do. + $this->assertSame('STRASSE', Utils::convertCase('Straße', 'upper')); + $this->assertSame('Hello World', Utils::convertCase('hello world', 'title')); + } + + public function testConvertCaseTitlecasesDigraphsToTheirTitleForm(): void + { + // U+01F3 dz titlecases to U+01F2 Dz, which is neither upper nor lower. + $this->assertSame("\u{01F2}", Utils::convertCase("\u{01F3}", 'title')); + } + + public function testConvertCaseFoldsForCaseInsensitiveComparison(): void + { + $this->assertSame( + Utils::convertCase('ÄÖÜ', 'fold'), + Utils::convertCase('äöü', 'fold'), + ); + } + + public function testSanitizeCharsReplacesDirectionalOverridesAtLevelOne(): void + { + // A right-to-left override can be used to disguise a file name or link. + $this->assertSame("a\u{202E}b", Utils::sanitizeChars("a\u{202E}b", 0)); + $this->assertSame("a\u{FFFD}b", Utils::sanitizeChars("a\u{202E}b", 1)); + } + + public function testNormalizeSpacesCollapsesExoticWhitespace(): void + { + $this->assertSame('a b', Utils::normalizeSpaces("a\u{00A0}b", true, true)); + } + + public function testSanitizeEntitiesReplacesEntitiesForControlCharacters(): void + { + $this->assertSame('�', Utils::sanitizeEntities('')); + $this->assertSame('A', Utils::sanitizeEntities('A')); + } + + public function testHtmlspecialcharsLeavesSingleQuotesAloneByDefault(): void + { + $this->assertSame('a"b\'c<>&', Utils::htmlspecialchars('a"b\'c<>&')); + $this->assertSame('a"b'c', Utils::htmlspecialchars('a"b\'c', ENT_QUOTES)); + } + + public function testHtmlspecialcharsDecodeRoundTrips(): void + { + $original = 'a"b&d'; + + $this->assertSame( + $original, + Utils::htmlspecialcharsDecode(Utils::htmlspecialchars($original, ENT_QUOTES)), + ); + } + + public function testJsonRoundTrips(): void + { + $this->assertSame('{"a":1}', Utils::jsonEncode(['a' => 1])); + $this->assertSame(['a' => 1], Utils::jsonDecode('{"a":1}', true)); + } + + #[DataProvider('entityLengthProvider')] + public function testEntityStrlenAcrossInputs(string $input, int $expected): void + { + $this->assertSame($expected, Utils::entityStrlen($input)); + } + + /*********************** + * Public static methods + ***********************/ + + /** + * @return array + */ + public static function entityLengthProvider(): array + { + return [ + 'empty' => ['', 0], + 'ascii' => ['abc', 3], + 'named entity' => ['&', 1], + 'numeric entity' => ['©', 1], + 'multibyte' => ["\u{00E9}\u{00E8}", 2], + 'mixed' => ['a&é', 3], + ]; + } } diff --git a/tests/Unit/UuidTest.php b/tests/Unit/UuidTest.php new file mode 100644 index 00000000000..b09b7e65c70 --- /dev/null +++ b/tests/Unit/UuidTest.php @@ -0,0 +1,107 @@ +assertMatchesRegularExpression( + '~^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$~', + (string) Uuid::create(4), + ); + } + + public function testGeneratedUuidsAreDistinct(): void + { + $this->assertNotSame((string) Uuid::create(4), (string) Uuid::create(4)); + } + + public function testTheVariantIsAlwaysTheRfcOne(): void + { + $this->assertSame(1, Uuid::create(4)->getVariant()); + $this->assertSame(1, Uuid::create(7)->getVariant()); + } + + public function testTheNilUuidRoundTripsAndReportsVersionZero(): void + { + $uuid = Uuid::createFromString(self::NIL); + + $this->assertSame(self::NIL, (string) $uuid); + $this->assertSame(0, $uuid->getVersion()); + } + + public function testTheBinaryFormIsSixteenBytes(): void + { + $this->assertSame(16, \strlen(Uuid::create(4)->getBinary())); + } + + public function testTheShortFormIsTwentyTwoCharacters(): void + { + $this->assertSame(22, \strlen(Uuid::create(4)->getShortForm())); + } + + public function testCompressAndExpandRoundTrip(): void + { + $uuid = (string) Uuid::create(4); + + $this->assertSame($uuid, Uuid::expand(Uuid::compress($uuid))); + } + + public function testStrictParsingRejectsRubbish(): void + { + $this->expectException(\ValueError::class); + + Uuid::createFromString('not-a-uuid', true); + } + + public function testVersionSevenUuidsSortByCreationOrder(): void + { + // Version 7 puts a millisecond timestamp in the high bits, so the string + // form is monotonic. That is the whole point of using it for keys. + $first = (string) Uuid::create(7); + usleep(2000); + $second = (string) Uuid::create(7); + + $this->assertLessThan(0, strcmp($first, $second)); + } + + #[DataProvider('versionProvider')] + public function testCreateProducesTheRequestedVersion(int $version): void + { + $this->assertSame($version, Uuid::create($version)->getVersion()); + } + + /*********************** + * Public static methods + ***********************/ + + /** + * @return array + */ + public static function versionProvider(): array + { + return [ + 'v4 random' => [4], + 'v7 time ordered' => [7], + ]; + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index facda09b2c3..a97e864c99f 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -51,3 +51,16 @@ $loader = require TESTS_BOARDDIR . '/vendor/autoload.php'; $loader->setPsr4('SMF\\', TESTS_BOARDDIR . '/Sources'); $loader->setPsr4('SMF\\Themes\\', TESTS_BOARDDIR . '/Themes'); + +/* + * Paths and the default language, which the Unicode and entity helpers need in + * order to locate their data files. These are the only pieces of Config the suite + * sets: no modSettings, no database credentials, nothing read from Settings.php. + * A test that needs more than this is an integration test. + */ +SMF\Config::$boarddir = (string) realpath(TESTS_BOARDDIR); +SMF\Config::$sourcedir = SMF\Config::$boarddir . '/Sources'; +SMF\Config::$packagesdir = SMF\Config::$boarddir . '/Packages'; +SMF\Config::$languagesdir = SMF\Config::$boarddir . '/Languages'; +SMF\Config::$cachedir = SMF\Config::$boarddir . '/cache'; +SMF\Config::$language = 'en_US'; From cf6ff4e6df9526b572e5f6bc88b5980d57f88542 Mon Sep 17 00:00:00 2001 From: albertlast Date: Wed, 29 Jul 2026 21:07:05 +0200 Subject: [PATCH 06/26] Parses memory settings that carry no unit designator memoryReturnBytes() removed the last character of the value before parsing the number, on the assumption that it is always a designator. PHP's shorthand notation is optional, so a plain byte count loses its last digit: '128' reads as 12, and '2097152' reads as 209715. Graphics\Image does exactly that, passing a computed byte count with no designator, so resizing an image asks for a tenth of the memory it just worked out that it needs. The other value with no designator is '-1', which means there is no limit. It read as 0, because intval('-') is 0, so setMemoryLimit() found the current limit to be smaller than anything and set one. On a server with no memory limit, asking for 128M capped it at 128M. Only strips the last character when it is one of the designators PHP accepts, and reports "no limit" as PHP_INT_MAX so that the callers comparing it against an amount they need do not each have to special case it. The dead is_integer() check went with it; the parameter is typed string. Co-Authored-By: Claude Opus 5 --- Sources/Sapi.php | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/Sources/Sapi.php b/Sources/Sapi.php index 2e6aba3779e..5ff927e1d0f 100644 --- a/Sources/Sapi.php +++ b/Sources/Sapi.php @@ -334,19 +334,28 @@ public static function setMemoryLimit(string $needed, bool $in_use = false): boo /** * Helper function to convert memory string settings to bytes * - * @param string $val The byte string, like '256M' or '1G'. + * The shorthand notation PHP accepts for these settings is optional, so the + * value may be a plain byte count with no designator at all. + * + * A negative value means there is no limit, which is reported as PHP_INT_MAX + * so that callers comparing it against an amount of memory they need do not + * have to special case it. + * + * @param string $val The byte string, like '256M', '1G' or '2097152'. * @return int The string converted to a proper integer in bytes. */ public static function memoryReturnBytes(string $val): int { - if (\is_integer($val)) { - return (int) $val; + $val = trim($val); + + // No limit at all. + if ((int) $val < 0) { + return PHP_INT_MAX; } - // Separate the number from the designator. - $val = trim($val); - $num = \intval(substr($val, 0, \strlen($val) - 1)); + // Separate the number from the designator, if there is one. $last = strtolower(substr($val, -1)); + $num = \in_array($last, ['g', 'm', 'k'], true) ? (int) substr($val, 0, -1) : (int) $val; // Convert to bytes. switch ($last) { From 560dfb2bb81721120dab15f6f233701aaa27b489 Mon Sep 17 00:00:00 2001 From: albertlast Date: Wed, 29 Jul 2026 21:08:13 +0200 Subject: [PATCH 07/26] Compares URL schemes case insensitively RFC 3986, section 3.1, makes scheme names case insensitive, and this class keeps the scheme exactly as it was written rather than normalizing it. isScheme() compared the two with in_array(), so a URL written with an uppercase scheme did not match its own name. That reaches two callers. isWebsite() stops recognising HTTP:// and HTTPS:// as websites, and the avatar handling in User treats a DATA: URI as though it were a remote address. Folds both sides before comparing, and makes the comparison strict while it is there. Co-Authored-By: Claude Opus 5 --- Sources/Url.php | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/Sources/Url.php b/Sources/Url.php index ae3421dfe36..b98c3c1f599 100644 --- a/Sources/Url.php +++ b/Sources/Url.php @@ -600,12 +600,23 @@ public function isWebsite(): bool /** * Check if this URL uses one of the specified schemes. * + * Scheme names are case insensitive, per RFC 3986, section 3.1, and this + * class does not normalize them, so both sides are folded before comparing. + * * @param string|string[] $scheme Schemes to check. * @return bool Whether the URL matches a scheme. */ public function isScheme(string|array $scheme): bool { - return !empty($this->scheme) && \in_array($this->scheme, array_map('strval', (array) $scheme)); + if (empty($this->scheme)) { + return false; + } + + return \in_array( + strtolower($this->scheme), + array_map(fn($s) => strtolower((string) $s), (array) $scheme), + true, + ); } /** From 7d656ce683615115d746e25da0c2a7ec5766f5bf Mon Sep 17 00:00:00 2001 From: albertlast Date: Wed, 29 Jul 2026 21:11:16 +0200 Subject: [PATCH 08/26] Turns the two noted defects into regression tests The memoryReturnBytes() and isScheme() cases were described in comments rather than asserted, because pinning down the behaviour would have preserved it. Now that both are fixed, they become tests. Verified to fail against the unfixed code: reverting the two source files alone fails exactly these six tests and nothing else. Co-Authored-By: Claude Opus 5 --- tests/Unit/SapiTest.php | 32 +++++++++++++++++++++++++------- tests/Unit/UrlTest.php | 26 ++++++++++++++++++++++---- 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/tests/Unit/SapiTest.php b/tests/Unit/SapiTest.php index 10908a726a9..8e5d3875feb 100644 --- a/tests/Unit/SapiTest.php +++ b/tests/Unit/SapiTest.php @@ -27,8 +27,29 @@ public function testTheSuiteRunsOnTheCommandLine(): void $this->assertTrue(Sapi::isCLI()); } + public function testNoMemoryLimitIsReportedAsMoreThanAnythingWillNeed(): void + { + // A memory_limit of -1 means unlimited. Reporting it as 0 made + // setMemoryLimit() decide the current limit was too small and impose + // one, so asking for 128M on an unlimited server capped it at 128M. + $this->assertSame(PHP_INT_MAX, Sapi::memoryReturnBytes('-1')); + } + + public function testAPlainByteCountKeepsItsLastDigit(): void + { + // The designator is optional, and Graphics\Image passes a computed byte + // count without one. Stripping the last character regardless turned this + // into a tenth of the memory that was actually asked for. + $this->assertSame(50000000, Sapi::memoryReturnBytes('50000000')); + } + + public function testSurroundingWhitespaceIsIgnored(): void + { + $this->assertSame(67108864, Sapi::memoryReturnBytes(' 64M ')); + } + #[DataProvider('memorySizeProvider')] - public function testMemoryReturnBytesUnderstandsUnitSuffixes(string $val, int $expected): void + public function testMemoryReturnBytes(string $val, int $expected): void { $this->assertSame($expected, Sapi::memoryReturnBytes($val)); } @@ -38,12 +59,6 @@ public function testMemoryReturnBytesUnderstandsUnitSuffixes(string $val, int $e ***********************/ /** - * Only values carrying a unit suffix are covered here. memoryReturnBytes() - * unconditionally strips the last character before parsing the number, so a - * plain byte count such as '128' or the '-1' that means "no limit" is not - * read correctly. Those cases are deliberately not asserted rather than - * pinned to the current behaviour. - * * @return array */ public static function memorySizeProvider(): array @@ -54,6 +69,9 @@ public static function memorySizeProvider(): array 'gigabytes' => ['1G', 1073741824], 'lowercase suffix' => ['256m', 268435456], 'zero megabytes' => ['0M', 0], + 'plain byte count' => ['128', 128], + 'zero' => ['0', 0], + 'empty' => ['', 0], ]; } } diff --git a/tests/Unit/UrlTest.php b/tests/Unit/UrlTest.php index 302aeff039b..913cc10147c 100644 --- a/tests/Unit/UrlTest.php +++ b/tests/Unit/UrlTest.php @@ -78,11 +78,29 @@ public function testIsSchemeMatchesTheSchemeAsWritten(): void $this->assertTrue((new Url('https://example.com'))->isScheme('https')); $this->assertTrue((new Url('https://example.com'))->isScheme(['http', 'https'])); $this->assertFalse((new Url('https://example.com'))->isScheme('ftp')); + } + + public function testIsSchemeIgnoresCaseOnBothSides(): void + { + // RFC 3986 section 3.1: scheme names are case insensitive. The scheme is + // not normalised on parsing, so the comparison has to fold it. + $this->assertTrue((new Url('HTTPS://example.com'))->isScheme('https')); + $this->assertTrue((new Url('https://example.com'))->isScheme('HTTPS')); + $this->assertTrue((new Url('HtTp://example.com'))->isScheme(['http', 'https'])); + } - // Not asserted here: an uppercase scheme does not match its lowercase - // name, because isScheme() compares the un-normalised scheme with - // in_array(). RFC 3986 makes schemes case insensitive, so that looks - // like a defect rather than something to pin down in a test. + public function testAnUppercaseSchemeIsStillAWebsite(): void + { + $this->assertTrue((new Url('HTTP://example.com'))->isWebsite()); + $this->assertTrue((new Url('HTTPS://example.com'))->isWebsite()); + $this->assertFalse((new Url('ftp://example.com'))->isWebsite()); + } + + public function testAnUppercaseDataUriIsRecognised(): void + { + // User's avatar handling asks isScheme('data') to decide whether the + // value is an inline image or a remote address. + $this->assertTrue((new Url('DATA:image/png;base64,AAAA'))->isScheme('data')); } #[DataProvider('validityProvider')] From 3e45ec45528731c8140e3c44c707ae6a52c83fda Mon Sep 17 00:00:00 2001 From: albertlast Date: Wed, 29 Jul 2026 21:26:51 +0200 Subject: [PATCH 09/26] Marks the ActionTrait test as covering a trait CoversClass on a trait is not a valid coverage target, and PHPUnit only says so when coverage is actually collected. The suite passed on its own and failed all five ActionTrait cases the moment anyone ran it with --coverage. Co-Authored-By: Claude Opus 5 --- tests/Unit/ActionTraitTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Unit/ActionTraitTest.php b/tests/Unit/ActionTraitTest.php index e62160fd57a..c194babe400 100644 --- a/tests/Unit/ActionTraitTest.php +++ b/tests/Unit/ActionTraitTest.php @@ -4,7 +4,7 @@ namespace SMF\Tests\Unit; -use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\CoversTrait; use PHPUnit\Framework\TestCase; use SMF\Actions\Agreement; use SMF\Actions\AgreementAccept; @@ -15,7 +15,7 @@ use SMF\Actions\UnreadReplies; use SMF\ActionTrait; -#[CoversClass(ActionTrait::class)] +#[CoversTrait(ActionTrait::class)] class ActionTraitTest extends TestCase { /**************** From 2c42cccc1e1084ab52f74057ccde83c318541e1f Mon Sep 17 00:00:00 2001 From: albertlast Date: Wed, 29 Jul 2026 23:58:35 +0200 Subject: [PATCH 10/26] Adds MySQL to the dev environment and makes it the default SMF supports MySQL and PostgreSQL, and until now this environment only offered one of them. Both database services now start, and SMF_DB_TYPE decides which one the generated Settings.php points at. It defaults to mysql, since that is what the great majority of installs run on. The two engines keep separate volumes, so a forum can be installed on each and switched between by deleting Settings.php and restarting. Settings.php wins over SMF_DB_TYPE once it exists, and the entrypoint says so rather than silently ignoring the variable. The postgres service is renamed from `db` to say what it is, and keeps `db` as a network alias so Settings.php files written by the previous version still resolve. Engine settings are pinned the same way the postgres side already pinned standard_conforming_strings: utf8mb4 and InnoDB, matching SMF's own table DDL. The collation is deliberately left at the charset default, because SMF sets CHARSET without COLLATE, and forcing one here would diverge from the tables it creates. Also corrects the everyday-use notes: php.ini, the vhost and the entrypoint are copied into the image, so editing them needs a rebuild rather than a restart. Co-Authored-By: Claude Opus 5 --- .docker/README.md | 119 ++++++++++++++++++++++++----------- .docker/env.example | 22 +++++-- .docker/mysql/init/10-smf.sh | 16 +++++ .docker/php/Dockerfile | 1 + .docker/php/entrypoint.sh | 60 +++++++++++++++--- compose.yaml | 81 +++++++++++++++++++----- 6 files changed, 233 insertions(+), 66 deletions(-) create mode 100644 .docker/mysql/init/10-smf.sh diff --git a/.docker/README.md b/.docker/README.md index d230980f984..43ed7bef21c 100644 --- a/.docker/README.md +++ b/.docker/README.md @@ -1,13 +1,15 @@ -# SMF development environment (PostgreSQL) +# SMF development environment A throwaway, reproducible local stack for working on SMF 3.0. Nothing here is part of the shipped forum — it lives in `.docker/` precisely so the CI checks (`check-smf-index.php`, `check-smf-license.php`) skip it. +Both database engines SMF supports are in the stack. **MySQL is the default.** + ## Requirements -Docker Desktop (Linux containers). Nothing else — no local PHP, Composer or -PostgreSQL install is needed. +Docker Desktop (Linux containers). Nothing else — no local PHP, Composer, MySQL +or PostgreSQL install is needed. ## Start @@ -19,36 +21,58 @@ First boot takes a few minutes: it builds the PHP image and runs `composer install` into `vendor/`. Watch it with `docker compose logs -f web` and wait for the `[smf-dev] ready` line. -| Service | URL | Notes | -| -------- | ----------------------- | ---------------------------------------- | -| Forum | http://localhost:8080 | The forum itself | -| Mailpit | http://localhost:8025 | Every mail the forum sends lands here | -| Adminer | http://localhost:8081 | Database browser, pre-pointed at `db` | -| Postgres | `localhost:5433` | For DBeaver/psql/etc. on the host | +| Service | URL / address | Notes | +| ---------- | ----------------------- | --------------------------------------- | +| Forum | http://localhost:8080 | The forum itself | +| Mailpit | http://localhost:8025 | Every mail the forum sends lands here | +| Adminer | http://localhost:8081 | Database browser, pre-pointed at MySQL | +| MySQL | `localhost:3307` | For a client on the host | +| PostgreSQL | `localhost:5433` | For a client on the host | + +Credentials are `smf` / `smf` / database `smf` on both engines. + +## Choosing the engine + +Both database services always start. `SMF_DB_TYPE` decides which one the forum +is pointed at, and it defaults to `mysql`: + +```sh +# In .env, or inline: +SMF_DB_TYPE=postgresql docker compose up -d +``` + +This only affects the `Settings.php` the entrypoint *generates*. Once the forum +is installed, `Settings.php` is what counts and changing the variable does +nothing — the entrypoint says so in the log rather than leaving you guessing. To +move to the other engine, delete `Settings.php` and `Settings_bak.php`, then +restart `web` and reinstall. -Database credentials are `smf` / `smf` / database `smf` throughout. +Because both engines run side by side with separate volumes, you can install on +one, switch, install on the other, and switch back: each database keeps its own +forum. ## Installing the forum -On first boot the entrypoint writes a `Settings.php` pre-filled for the -`db` service and copies `other/install.php` to the web root, so +On first boot the entrypoint writes a `Settings.php` pre-filled for the chosen +engine and copies `other/install.php` to the web root, so http://localhost:8080 redirects into the installer. The installer does **not** read its form defaults from `Settings.php` — it uses -the hardcoded defaults in `SMF\Db\APIs\PostgreSQL`. On the *Database Server -Settings* step you must enter: - -| Field | Value | -| ------------- | ------------ | -| Database type | `PostgreSQL` | -| Server | `db` | -| Port | `5432` | -| Username | `smf` | -| Password | `smf` | -| Database name | `smf` | - -Port `5432` is correct here: `5433` is only how the host reaches postgres from -outside Docker. Containers talk to each other on the internal network. +the hardcoded defaults in the database API class. On the *Database Server +Settings* step, enter: + +| Field | MySQL | PostgreSQL | +| ------------- | ------- | ------------ | +| Database type | `MySQL` | `PostgreSQL` | +| Server | `mysql` | `postgres` | +| Port | `3306` | `5432` | +| Username | `smf` | `smf` | +| Password | `smf` | `smf` | +| Database name | `smf` | `smf` | + +The internal ports are correct here: `3307` and `5433` are only how the *host* +reaches the databases from outside Docker. Containers talk to each other on the +compose network. When the installer finishes, delete `install.php` from the repo root — while it exists, `Settings.php` redirects every request back into the installer. @@ -56,18 +80,23 @@ exists, `Settings.php` redirects every request back into the installer. ## Everyday use ```sh -docker compose logs -f web # apache + php errors, live -docker compose exec web bash # shell in the web container -docker compose exec db psql -U smf # psql on the forum database +docker compose logs -f web # apache + php errors, live +docker compose exec web bash # shell in the web container + +docker compose exec mysql mysql -usmf -psmf smf # mysql client +docker compose exec postgres psql -U smf # psql docker compose exec web composer install docker compose exec web composer lint -docker compose restart web # after changing php.ini or the vhost -docker compose down # stop, keep the database -docker compose down -v # stop and destroy the database +docker compose up -d --build web # after changing anything in .docker/php/ +docker compose down # stop, keep both databases +docker compose down -v # stop and destroy both databases ``` +`php.ini`, the vhost and the entrypoint are copied into the image at build time, +not bind-mounted, so a plain `restart` will not pick up edits to them. Rebuild. + The repository is bind-mounted at `/var/www/html`, so edits on the host are live on the next request. Opcache is on but revalidates every request, so you never need to restart for a PHP change. @@ -77,20 +106,33 @@ To reinstall from scratch: `docker compose down -v`, delete `Settings.php` and ## Configuration -`compose.yaml` works with no `.env` file. To change ports, versions or -credentials, copy `.docker/env.example` to `.env` in the repository root. +`compose.yaml` works with no `.env` file. To change ports, versions, the engine +or credentials, copy `.docker/env.example` to `.env` in the repository root. + +The `postgres` service also answers to the hostname `db`, which is what +`Settings.php` files generated before MySQL was added point at. ## What is in the image PHP 8.4 on Apache, with everything `other/requirements.md` lists: -- Required: `mbstring`, `fileinfo`, `pgsql` (SMF checks for `pg_connect`), plus - `mysqli` so the installer still offers MySQL. +- Required: `mbstring`, `fileinfo`, and both `mysqli` and `pgsql` (SMF checks + for `pg_connect`), so either engine can be chosen at install time. - Recommended: `gd`, `intl`, `curl`, `exif`, `ftp`, `xsl`, and `zip`. -- `standard_conforming_strings` is set `on` at database level, as SMF requires. +- Both database command line clients, for the `docker compose exec` recipes + above and for the entrypoint's readiness check. - `mail()` is routed through msmtp into Mailpit, so no mail can escape the machine. +Engine settings SMF asks for are pinned at server level rather than left to the +image defaults: + +- PostgreSQL: `standard_conforming_strings = on`, as `requirements.md` requires. +- MySQL: `utf8mb4` and InnoDB, matching SMF's own table DDL. The collation is + deliberately left at the charset default, because SMF sets `CHARSET` without + `COLLATE`; forcing a different one here would diverge from the tables it + creates. + ## Files ``` @@ -100,6 +142,7 @@ compose.yaml the stack .docker/php/vhost.conf apache vhost .docker/php/msmtprc mail() -> mailpit .docker/php/entrypoint.sh composer install, Settings.php, permissions -.docker/postgres/init/10-smf.sh runs once on first database creation +.docker/mysql/init/10-smf.sh runs once on first mysql database creation +.docker/postgres/init/10-smf.sh runs once on first postgres database creation .docker/env.example optional overrides ``` diff --git a/.docker/env.example b/.docker/env.example index 52db52142d6..31736f2a186 100644 --- a/.docker/env.example +++ b/.docker/env.example @@ -1,17 +1,31 @@ # Copy to the repository root as `.env` to override any of the defaults. # compose.yaml works without this file. +# Which engine the forum runs on: mysql (default) or postgresql. +# Both database services start either way. This only decides what the generated +# Settings.php points at, so it has no effect once the forum is installed -- +# Settings.php wins from then on. +SMF_DB_TYPE=mysql + # Host ports WEB_PORT=8080 ADMINER_PORT=8081 MAILPIT_PORT=8025 +MYSQL_PORT=3307 POSTGRES_PORT=5433 # Versions PHP_VERSION=8.4 +MYSQL_VERSION=8.4 POSTGRES_VERSION=17-alpine -# Database credentials (dev only) -POSTGRES_DB=smf -POSTGRES_USER=smf -POSTGRES_PASSWORD=smf +# Database credentials (dev only). Shared by both engines so that switching +# SMF_DB_TYPE needs no other change. +DB_NAME=smf +DB_USER=smf +DB_PASSWORD=smf +DB_ROOT_PASSWORD=smf + +# Which service Adminer pre-fills in its server field. Service name, not engine +# name: mysql or postgres. +ADMINER_SERVER=mysql diff --git a/.docker/mysql/init/10-smf.sh b/.docker/mysql/init/10-smf.sh new file mode 100644 index 00000000000..6f1589bdaa5 --- /dev/null +++ b/.docker/mysql/init/10-smf.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# Runs once, on first initialisation of the mysql data volume. +set -eu + +# SMF creates its own tables as InnoDB/utf8mb4, but the database's own default is +# what anything created outside that path inherits. Pinning it means it cannot +# drift out from under the forum, the same reason the postgres side pins +# standard_conforming_strings. +# +# The collation is left to whatever utf8mb4 defaults to on this server, because +# that is what SMF's tables get: its DDL sets CHARSET but never COLLATE. +mysql --protocol=socket -uroot -p"$MYSQL_ROOT_PASSWORD" <<-EOSQL + ALTER DATABASE \`${MYSQL_DATABASE}\` CHARACTER SET utf8mb4; +EOSQL + +echo "[smf-dev] database ${MYSQL_DATABASE} initialised" diff --git a/.docker/php/Dockerfile b/.docker/php/Dockerfile index a9588f1df5d..fcfb0ed41dd 100644 --- a/.docker/php/Dockerfile +++ b/.docker/php/Dockerfile @@ -29,6 +29,7 @@ RUN install-php-extensions \ msmtp \ msmtp-mta \ postgresql-client \ + default-mysql-client \ && rm -rf /var/lib/apt/lists/* # Route mail() at Mailpit so outgoing forum mail is captured, never sent. diff --git a/.docker/php/entrypoint.sh b/.docker/php/entrypoint.sh index 676ed275c00..1609dcc31ee 100644 --- a/.docker/php/entrypoint.sh +++ b/.docker/php/entrypoint.sh @@ -21,22 +21,53 @@ if [ ! -f "$BOARD_DIR/vendor/autoload.php" ]; then fi # ------------------------------------------------------------------- database -log "waiting for postgres at ${SMF_DB_SERVER}:${SMF_DB_PORT}" -until pg_isready -h "$SMF_DB_SERVER" -p "$SMF_DB_PORT" -U "$SMF_DB_USER" -q; do - sleep 1 -done -log 'postgres is accepting connections' +# SMF_DB_TYPE picks the engine. Both are running; only the one the forum is +# pointed at gets waited for and written into Settings.php. +case "${SMF_DB_TYPE:-mysql}" in + mysql|mysqli|mariadb) + DB_TYPE=mysql + DB_SERVER="${SMF_MYSQL_SERVER:-mysql}" + DB_PORT="${SMF_MYSQL_PORT:-3306}" + ;; + + postgresql|postgres|pgsql) + DB_TYPE=postgresql + DB_SERVER="${SMF_POSTGRES_SERVER:-postgres}" + DB_PORT="${SMF_POSTGRES_PORT:-5432}" + ;; + + *) + log "SMF_DB_TYPE='${SMF_DB_TYPE}' is not a type SMF supports (mysql, postgresql)" + exit 1 + ;; +esac + +log "waiting for ${DB_TYPE} at ${DB_SERVER}:${DB_PORT}" + +if [ "$DB_TYPE" = 'postgresql' ]; then + until pg_isready -h "$DB_SERVER" -p "$DB_PORT" -U "$SMF_DB_USER" -q; do + sleep 1 + done +else + # Any answer at all means the server is listening; this deliberately does + # not authenticate, so it works before the init scripts have finished. + until mysqladmin ping -h "$DB_SERVER" -P "$DB_PORT" --silent >/dev/null 2>&1; do + sleep 1 + done +fi + +log "${DB_TYPE} is accepting connections" # --------------------------------------------------------------- installer bits # Settings.php redirects to install.php whenever install.php is present, so both # files only get placed while the forum has not been installed yet. if [ ! -f "$BOARD_DIR/Settings.php" ]; then - log 'generating Settings.php pre-filled for the postgres service' + log "generating Settings.php pre-filled for the ${DB_TYPE} service" sed \ - -e "s|^\$db_type = 'mysql';|\$db_type = 'postgresql';|" \ - -e "s|^\$db_port = 0;|\$db_port = ${SMF_DB_PORT};|" \ - -e "s|^\$db_server = 'localhost';|\$db_server = '${SMF_DB_SERVER}';|" \ + -e "s|^\$db_type = 'mysql';|\$db_type = '${DB_TYPE}';|" \ + -e "s|^\$db_port = 0;|\$db_port = ${DB_PORT};|" \ + -e "s|^\$db_server = 'localhost';|\$db_server = '${DB_SERVER}';|" \ -e "s|^\$db_name = 'smf';|\$db_name = '${SMF_DB_NAME}';|" \ -e "s|^\$db_user = 'root';|\$db_user = '${SMF_DB_USER}';|" \ -e "s|^\$db_passwd = '';|\$db_passwd = '${SMF_DB_PASSWD}';|" \ @@ -47,6 +78,17 @@ if [ ! -f "$BOARD_DIR/Settings.php" ]; then cp "$BOARD_DIR/other/install.php" "$BOARD_DIR/install.php" log "installer ready -- open ${SMF_BOARDURL}/install.php" +else + # Already installed, and Settings.php wins over SMF_DB_TYPE. Say so rather + # than leaving someone wondering why switching the variable did nothing. + # The installer writes this back with its own capitalisation ('PostgreSQL'), + # so compare case-insensitively or the note fires on every restart. + installed_type=$(sed -n "s|^\\\$db_type = '\\([^']*\\)';.*|\\1|p" "$BOARD_DIR/Settings.php" | head -n 1 | tr '[:upper:]' '[:lower:]') + + if [ -n "$installed_type" ] && [ "$installed_type" != "$DB_TYPE" ]; then + log "note: Settings.php is installed against ${installed_type}, not ${DB_TYPE}" + log ' to move, delete Settings.php and Settings_bak.php, then restart' + fi fi [ -f "$BOARD_DIR/Settings_bak.php" ] || cp "$BOARD_DIR/Settings.php" "$BOARD_DIR/Settings_bak.php" diff --git a/compose.yaml b/compose.yaml index be97189db49..cac7f51584f 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,8 +1,11 @@ -# SMF development environment (PostgreSQL). +# SMF development environment. # # docker compose up -d --build # -> http://localhost:8080/install.php # +# Both database engines SMF supports are running. MySQL is what the forum is +# pointed at by default; set SMF_DB_TYPE=postgresql in .env to use the other. +# # Everything below has a working default, so no .env file is required. Copy # .docker/env.example to .env if you want to change ports or credentials. @@ -21,33 +24,76 @@ services: # The checkout is bind-mounted, so edits on the host are live immediately. - .:/var/www/html environment: - SMF_DB_SERVER: db - SMF_DB_PORT: "5432" - SMF_DB_NAME: ${POSTGRES_DB:-smf} - SMF_DB_USER: ${POSTGRES_USER:-smf} - SMF_DB_PASSWD: ${POSTGRES_PASSWORD:-smf} + # Which engine the generated Settings.php points at: mysql or postgresql. + SMF_DB_TYPE: ${SMF_DB_TYPE:-mysql} + SMF_DB_NAME: ${DB_NAME:-smf} + SMF_DB_USER: ${DB_USER:-smf} + SMF_DB_PASSWD: ${DB_PASSWORD:-smf} + # Per-engine host and port, so switching SMF_DB_TYPE is the only change + # needed. These are container-internal, not the host ports below. + SMF_MYSQL_SERVER: mysql + SMF_MYSQL_PORT: "3306" + SMF_POSTGRES_SERVER: postgres + SMF_POSTGRES_PORT: "5432" SMF_BOARDURL: http://localhost:${WEB_PORT:-8080} depends_on: - db: + mysql: + condition: service_healthy + postgres: condition: service_healthy restart: unless-stopped - db: + mysql: + image: mysql:${MYSQL_VERSION:-8.4} + ports: + # Exposed on 3307 by default so it cannot collide with a local mysql. + - "${MYSQL_PORT:-3307}:3306" + environment: + MYSQL_DATABASE: ${DB_NAME:-smf} + MYSQL_USER: ${DB_USER:-smf} + MYSQL_PASSWORD: ${DB_PASSWORD:-smf} + MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-smf} + # SMF creates its tables as InnoDB/utf8mb4, so match that at server level + # rather than relying on whichever defaults the image ships with. The + # collation is deliberately left to the charset default: SMF's own DDL says + # `DEFAULT CHARSET=utf8mb4` with no COLLATE, so forcing a different one here + # would only apply to objects SMF did not create, and the two would diverge. + command: + - --character-set-server=utf8mb4 + - --default-storage-engine=InnoDB + volumes: + - mysql-data:/var/lib/mysql + - ./.docker/mysql/init:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 --silent"] + interval: 3s + timeout: 5s + retries: 40 + start_period: 20s + restart: unless-stopped + + postgres: image: postgres:${POSTGRES_VERSION:-17-alpine} + # Also reachable as `db`, which is what installs made before MySQL was + # added have in their Settings.php. + networks: + default: + aliases: + - db ports: # Exposed on 5433 by default so it cannot collide with a local postgres. - "${POSTGRES_PORT:-5433}:5432" environment: - POSTGRES_DB: ${POSTGRES_DB:-smf} - POSTGRES_USER: ${POSTGRES_USER:-smf} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-smf} + POSTGRES_DB: ${DB_NAME:-smf} + POSTGRES_USER: ${DB_USER:-smf} + POSTGRES_PASSWORD: ${DB_PASSWORD:-smf} # Dev-only: skip the password prompt cost for local connections. POSTGRES_HOST_AUTH_METHOD: scram-sha-256 volumes: - db-data:/var/lib/postgresql/data - ./.docker/postgres/init:/docker-entrypoint-initdb.d:ro healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-smf} -d ${POSTGRES_DB:-smf}"] + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-smf} -d ${DB_NAME:-smf}"] interval: 3s timeout: 3s retries: 20 @@ -60,17 +106,22 @@ services: - "${MAILPIT_PORT:-8025}:8025" restart: unless-stopped - # Browse and query the database at http://localhost:8081 + # Browse and query either database at http://localhost:8081. The server field + # is pre-filled with whichever engine the forum is using; type the other + # service name in to look at it instead. adminer: image: adminer:latest ports: - "${ADMINER_PORT:-8081}:8080" environment: - ADMINER_DEFAULT_SERVER: db + # Service name, not engine name: mysql or postgres. + ADMINER_DEFAULT_SERVER: ${ADMINER_SERVER:-mysql} ADMINER_DESIGN: dracula depends_on: - - db + - mysql + - postgres restart: unless-stopped volumes: db-data: + mysql-data: From d4f484cc343c23d6c49362b2338d3ff2a1b501f8 Mon Sep 17 00:00:00 2001 From: albertlast Date: Thu, 30 Jul 2026 22:55:32 +0200 Subject: [PATCH 11/26] Documents the postgres log as the way to debug SQL errors PostgreSQL logs every statement that errors together with the SQL that caused it, with no configuration needed, and the log is only on the container stderr. That makes `docker compose logs postgres` the most useful debugging tool in the stack, and nothing said so. MySQL logs server errors only, never the client statement that failed, so the note points out the asymmetry: now that mysql is the default engine, a suspected SQL problem is worth reproducing on postgres. Co-Authored-By: Claude Opus 5 --- .docker/README.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/.docker/README.md b/.docker/README.md index 43ed7bef21c..2ba2f277fb3 100644 --- a/.docker/README.md +++ b/.docker/README.md @@ -81,6 +81,7 @@ exists, `Settings.php` redirects every request back into the installer. ```sh docker compose logs -f web # apache + php errors, live +docker compose logs -f postgres # every failing query, with its SQL docker compose exec web bash # shell in the web container docker compose exec mysql mysql -usmf -psmf smf # mysql client @@ -104,6 +105,30 @@ never need to restart for a PHP change. To reinstall from scratch: `docker compose down -v`, delete `Settings.php` and `Settings_bak.php`, then `docker compose up -d`. +## Debugging SQL with the PostgreSQL log + +The `postgres` log is the best tool in the stack for tracking down a broken +query. PostgreSQL logs every statement that errors together with the SQL that +caused it, always and without any configuration: + +``` +2026-01-01 12:00:00.000 UTC [98] ERROR: relation "nope" does not exist at character 15 +2026-01-01 12:00:00.000 UTC [98] STATEMENT: select * from nope; +``` + +Nothing is written to a file inside the container, so the compose log above is +where to look. Add `--since 5m` to it to skip past the startup noise. + +MySQL has no equivalent: it logs server errors only, never the client statement +that failed, so a query SMF gets wrong leaves no trace in its log. Since MySQL +is the default engine, a suspected SQL problem is worth reproducing against +PostgreSQL — install on it once and you can switch back and forth, because each +database keeps its own forum. + +Only failing statements are logged. Successful ones, timings and connections +are not, so this shows you the queries that break, not the ones that merely +return the wrong thing. + ## Configuration `compose.yaml` works with no `.env` file. To change ports, versions, the engine From b5f342f0e2025eaf5974393ec5ceaea6078e2d48 Mon Sep 17 00:00:00 2001 From: albertlast Date: Fri, 31 Jul 2026 20:20:17 +0200 Subject: [PATCH 12/26] Documents when the unit test suite can cover a change The testing notes described the suite mainly as a limitation, which left agents with no way to tell whether the code in front of them was reachable from it. Sets out the expectation that a reachable change carries a test, and lists the cases that work with the examples already in tests/Unit/: pure helpers, value objects, class-level behaviour, protected helpers through reflection, and modSettings keys the test sets itself. Also names the strict-mode traps and the two ways the style fixer rearranges a test file. Corrects the CI claim as well; phpunit.yml only runs on pull requests and on pushes to release-3.0. Co-Authored-By: Claude Opus 5 Signed-off-by: albertlast --- AGENTS.md | 78 +++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 67 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index af1ede645eb..e58a652d280 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,18 +79,78 @@ param, throws, return. ## Verifying a change -There is a unit test suite, but it is small and deliberately narrow: +### Tests + +There is a unit test suite. It is small and deliberately narrow, but where it reaches, +it is the only automated proof that a change does what it claims: ```bash composer test # or: vendor/bin/phpunit ``` -It runs without a database, a `Settings.php` or a request: `tests/bootstrap.php` only -defines the constants `index.php` would define and points the autoloader at `Sources/`. -That covers pure helpers and class-level behaviour. **Anything reaching -`Config::$modSettings`, `User::$me` or `Db::$db` is out of scope**, which is most of the -forum. Add a test there when the code you are touching is reachable that way; do not -contort production code to make it testable. +CI runs it on every pull request, and on pushes to `release-3.0`, via +`.github/workflows/phpunit.yml`. Feature branches are only checked once they are in a PR, +so run it locally. + +**The expectation: if the code you touched is reachable from this suite, your change +adds or updates a test in the same commit.** A bug fix lands as a regression test that +fails before the fix and passes after it, with a comment saying what went wrong — see +`SapiTest::testAPlainByteCountKeepsItsLastDigit()` for the shape. When the code is not +reachable, say so explicitly in the PR description rather than leaving it unsaid; do not +contort production code, add mocks or fake a database to force something under test. + +#### When a test is possible + +`tests/bootstrap.php` defines the constants `index.php` would define, points the +autoloader at `Sources/` and sets `Config::$boarddir`, `$sourcedir`, `$packagesdir`, +`$languagesdir`, `$cachedir` and `$language`. That is all. No `Settings.php`, no +database, no request. Within those limits the following are all testable, and each has a +worked example in `tests/Unit/`: + +- **Pure and static helpers**: `Utils::buildRegex()`, `Sapi::memoryReturnBytes()`, + `Security::hashPassword()`. Cheap to cover with a `#[DataProvider]`. +- **Value objects that parse or normalise a string**: `IP`, `Url`, `Uuid`, + `TimeInterval`, `Punycode`. Construct one and assert on the result. +- **Class-level behaviour that needs no state**: late static binding, shared statics, + what `Foo::load()` returns. `ActionTraitTest` is entirely this. +- **Protected and private helpers**, through `ReflectionMethod`, when the public entry + point around them needs a database but the helper itself does not + (`CreatePostNotifyTest::getTimeOffset()`). +- **Code that reads a few `Config::$modSettings` keys.** Set them in `setUp()` and + `unset()` them in `tearDown()`. PHPUnit does not reset SMF's statics between tests, so + a key left behind leaks into every test that follows. +- **Anything that only needs the language or Unicode data files**, since the bootstrap + sets the paths they look in. + +#### When it is not + +- Anything calling `Db::$db` — there is no connection, and faking one is not worth it. +- Anything reading `User::$me`, the session, `$_GET`/`$_POST`/`$_SERVER`, or expecting a + loaded theme or `Utils::$context`. +- Anything that emits output or sends headers. `beStrictAboutOutputDuringTests` is on, so + a stray `echo` fails the test rather than being swallowed. + +`failOnRisky` and `failOnWarning` are on as well: a test that asserts nothing is a +failure, not a pass. + +#### Writing one + +`tests/Unit/Test.php`, namespace `SMF\Tests\Unit`, `declare(strict_types=1)`, +extending `PHPUnit\Framework\TestCase`, with `#[CoversClass]` (or `#[CoversTrait]` for a +trait) on the class. Name the test after the behaviour, not the method — +`testItNormalisesIPv6ToItsShortestForm()`, not `testConstruct()`. New directories need +the usual `index.php` stub. + +The code style rules apply to tests too, so run `composer lint-fix` on them. Two +consequences of the fixer worth knowing before you fight it: + +- Data providers are `public static`, so `ordered_class_elements` moves them *below* the + public test methods, into their own `Public static methods` banner. +- The `SMF/section_comments` fixer inserts a banner between an attribute and the method + it belongs to. Do not let a method carrying `#[DataProvider]` be the first one in its + group; `CreatePostNotifyTest` carries a note about this. + +### Running the forum The rest of CI only proves the code parses (`phplint` on 8.4 and 8.5) and is formatted. So a fully green PR still tells you very little about whether a change works. Verify by @@ -126,10 +186,6 @@ docker compose exec postgres psql -U smf -d smf -c 'SELECT * FROM smf_log_errors `smf_log_errors` is the first place to look. Many failures are recorded there rather than shown, especially anything in a background task. -Some code is reachable with only the autoloader plus the constants that `index.php` -defines, which is enough to exercise pure helpers without a database. Anything that -touches `User::$me` or `Db::$db` needs a real request or fixtures. - ## Things that bite in this codebase - **Typed properties with no default throw when read before assignment.** Several are From 459b2719ca265d35e2c7dd5f9362456417856bf4 Mon Sep 17 00:00:00 2001 From: albertlast Date: Sun, 2 Aug 2026 07:44:46 +0200 Subject: [PATCH 13/26] Reports maintenance tool failures on the command line Maintenance::exit() renders the tool's templates, and those are the only place errors are ever shown. On the command line it takes the fallthrough path instead and goes straight to die(), so nothing was reported and the exit status was always 0: a scripted install that died on step three looked exactly like one that had finished. ToolsBase::updateSettingsFile() made the same assumption more directly, calling die() outright when Settings.php could not be written rather than recording the error the way the web path does. Writes the warnings and errors to stderr and exits non-zero when the tool actually failed. A step that merely wants input it was not given sets neither, so pausing part way through is still a success - the installer is meant to be called more than once - and that case now says which step it stopped on instead of nothing at all. Co-Authored-By: Claude Opus 5 Signed-off-by: albertlast --- Sources/Maintenance/Maintenance.php | 56 +++++++++++++++++++++++++ Sources/Maintenance/Tools/ToolsBase.php | 9 ++-- 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/Sources/Maintenance/Maintenance.php b/Sources/Maintenance/Maintenance.php index 8809ee6f5fb..1b2a1b2c04f 100644 --- a/Sources/Maintenance/Maintenance.php +++ b/Sources/Maintenance/Maintenance.php @@ -808,6 +808,46 @@ public static function setQueryString(): string */ public static function exit(bool $fallthrough = false): void { + // On the command line there is no template to render, so everything the + // tool wanted to tell us has nowhere to go: a scripted install that died + // on step three looks exactly like one that finished. Put the problems on + // stderr and leave a non-zero status behind instead. + // + // A step that simply needs more input sets neither of these, so pausing + // part way through is still a success -- the installer is meant to be + // called more than once. + if ($fallthrough && Sapi::isCLI()) { + foreach (self::$warnings as $warning) { + fwrite(STDERR, 'warning: ' . self::plainText($warning) . "\n"); + } + + $problems = self::$errors; + + if (self::$fatal_error !== '') { + array_unshift($problems, self::$fatal_error); + } + + if ($problems !== []) { + foreach ($problems as $problem) { + fwrite(STDERR, 'error: ' . self::plainText($problem) . "\n"); + } + + exit(1); + } + + // Nothing went wrong, but we are not finished either: a step wanted + // input it was not given. Say which one, so a script that has to be + // run more than once can tell where it got to. + if (isset(self::$tool) && self::getCurrentStep() <= \count(self::$tool->getSteps())) { + fwrite( + STDERR, + 'stopped at step ' . self::getCurrentStep() + . ' of ' . \count(self::$tool->getSteps()) + . ' (' . (self::$tool->getSteps()[self::getCurrentStep()]?->getName() ?? 'unknown') . ")\n", + ); + } + } + // We usually dump our templates out. if (!$fallthrough) { // Send character set. @@ -920,4 +960,20 @@ private static function setCurrentStep(?int $step = null): void { $_GET['step'] = $step ?? (self::getCurrentStep() + 1); } + + /** + * Flattens one of our messages into something worth reading in a terminal. + * + * The steps build these for a browser, so they arrive carrying markup: the + * database errors in particular wrap the driver's own message in a div. + * + * @param string $message The message, as the step wrote it. + * @return string The same message, without the markup. + */ + private static function plainText(string $message): string + { + $message = preg_replace('~~i', "\n", $message) ?? $message; + + return trim(html_entity_decode(strip_tags($message), ENT_QUOTES | ENT_HTML5, 'UTF-8')); + } } diff --git a/Sources/Maintenance/Tools/ToolsBase.php b/Sources/Maintenance/Tools/ToolsBase.php index 7193b974371..d8c0a0be4d4 100644 --- a/Sources/Maintenance/Tools/ToolsBase.php +++ b/Sources/Maintenance/Tools/ToolsBase.php @@ -612,10 +612,11 @@ public function updateSettingsFile(array $config_vars, ?bool $keep_quotes = null if (!Config::updateSettingsFile($config_vars, $keep_quotes, $rebuild)) { $this->logProgress(Lang::getTxt('log_failed_with_error', ['error' => Lang::getTxt('settings_error', file: 'Maintenance')], file: 'Maintenance')); - if (Sapi::isCLI()) { - die(); - } - + // This used to die() outright on the command line, which reported + // nothing and exited 0 -- a scripted install that could not write + // Settings.php looked exactly like one that worked. Recording the + // error and returning stops the run just as firmly, and now the + // caller gets to say why. Maintenance::$fatal_error = Lang::getTxt('settings_error', file: 'Maintenance'); return false; From 11fe20d2959d6d95e8b09a6f94f1dcc97b45458a Mon Sep 17 00:00:00 2001 From: albertlast Date: Sun, 2 Aug 2026 07:44:59 +0200 Subject: [PATCH 14/26] Stops the installer assuming there is a web request Two things in the installer only hold when a browser is on the other end, and both are reached before the forum exists, so neither could be worked around from outside. defaultHost() reads $_SERVER['SERVER_NAME'] and ['SERVER_PORT'] whenever HTTP_HOST is absent. On the command line none of the three is set, so every run began with an undefined index warning. Falls back to localhost: the value only seeds the suggested board URL on the form, and a scripted install passes its own boardurl in. forumSettings() then built the same suggestion with substr($self, 0, strrpos($self, '/')). getSelf() is $_SERVER['PHP_SELF'], which in a request is a rooted path but on the command line is whatever was typed - usually a bare 'install.php' with no directory in it. strrpos() returns false, and substr() with a false length is fatal on PHP 8, so the installer died here on every CLI run. While in there: an unrecognised database type reported Lang::getTxt('upgrade_unknown_error'), which is not a string that exists. The fatal error was therefore blank in the browser too. Names the type that was rejected and the ones that would have been accepted, which matters most on the command line where the type is typed by hand rather than picked from a list of exactly those keys. Co-Authored-By: Claude Opus 5 Signed-off-by: albertlast --- Languages/en_US/Maintenance.php | 1 + Sources/Maintenance/Tools/Install.php | 38 ++++++++++++++++++++++++--- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/Languages/en_US/Maintenance.php b/Languages/en_US/Maintenance.php index 3f3f4ee4477..21dceeb9f5b 100644 --- a/Languages/en_US/Maintenance.php +++ b/Languages/en_US/Maintenance.php @@ -124,6 +124,7 @@ It is recommended that you visit the Simple Machines website to ensure you are installing the latest version.'; $txt['error_already_installed'] = 'The installer has detected that you already have SMF installed. It is strongly advised that you do not try to overwrite an existing installation, continuing with installation may result in the loss or corruption of existing data.

If you wish to upgrade please visit the Simple Machines Website and download the latest upgrade package.

If you wish to overwrite your existing installation, including all data, it is recommended that you delete the existing database tables and replace Settings.php and try again.'; $txt['error_db_missing'] = 'The installer was unable to detect any database support in PHP. Please ask your host to ensure that PHP was compiled with the desired database, or that the proper extension is being loaded.'; +$txt['error_db_type_unknown'] = '“{db_type}” is not a database type this server supports. Supported types: {supported}.'; $txt['error_session_missing'] = 'The installer was unable to detect sessions support in your server’s installation of PHP. Please ask your host to ensure that PHP was compiled with session support (which in fact is the PHP default, meaning your host currently has explicitly disabled it).'; $txt['error_missing_files'] = 'Unable to find crucial installation files in the directory of this script!

Please make sure you uploaded the entire installation package, including the sql file, and then try again.'; $txt['error_session_save_path'] = 'Please inform your host that the session.save_path specified in php.ini is not valid! It needs to be changed to a directory that exists and is writable by the user PHP is running under.
'; diff --git a/Sources/Maintenance/Tools/Install.php b/Sources/Maintenance/Tools/Install.php index 51c1879883a..3a0a526b2a1 100644 --- a/Sources/Maintenance/Tools/Install.php +++ b/Sources/Maintenance/Tools/Install.php @@ -439,7 +439,19 @@ public function databaseSettings(): bool $db_prefix = $_POST['db_prefix']; if (!isset(Maintenance::$context['databases'][$db_type])) { - Maintenance::$fatal_error = Lang::getTxt('upgrade_unknown_error', file: 'Maintenance'); + // upgrade_unknown_error, which used to be reported here, does not + // exist -- so this produced an empty fatal error and left no clue + // what had gone wrong. Naming the type and the alternatives matters + // most on the command line, where the type is typed out by hand + // rather than picked from a list of exactly these keys. + Maintenance::$fatal_error = Lang::getTxt( + 'error_db_type_unknown', + [ + 'db_type' => $db_type, + 'supported' => Lang::sentenceList(array_keys(Maintenance::$context['databases'])), + ], + file: 'Maintenance', + ); $this->logProgress(Maintenance::$fatal_error); return false; @@ -609,7 +621,15 @@ public function forumSettings(): bool Db::load(); // Now, to put what we've learned together... and add a path. - Maintenance::$context['detected_url'] = 'http' . (Sapi::httpsOn() ? 's' : '') . '://' . $this->defaultHost() . substr(Maintenance::getSelf(), 0, strrpos(Maintenance::getSelf(), '/')); + // getSelf() is $_SERVER['PHP_SELF'], which in a request is a rooted path + // but on the command line is whatever was typed -- usually a bare + // 'install.php' with no directory in it at all. strrpos() then returns + // false, and substr() with a false length is fatal on PHP 8, so the + // installer died here on every CLI run. + $self = Maintenance::getSelf(); + $last_slash = strrpos($self, '/'); + + Maintenance::$context['detected_url'] = 'http' . (Sapi::httpsOn() ? 's' : '') . '://' . $this->defaultHost() . ($last_slash === false ? '' : substr($self, 0, $last_slash)); // Check if the database sessions will even work. Maintenance::$context['test_dbsession'] = (\ini_get('session.auto_start') != 1); @@ -1416,7 +1436,19 @@ private function saveProgress(): bool */ private function defaultHost(): string { - return empty($_SERVER['HTTP_HOST']) ? $_SERVER['SERVER_NAME'] . (empty($_SERVER['SERVER_PORT']) || $_SERVER['SERVER_PORT'] == '80' ? '' : ':' . $_SERVER['SERVER_PORT']) : $_SERVER['HTTP_HOST']; + if (!empty($_SERVER['HTTP_HOST'])) { + return $_SERVER['HTTP_HOST']; + } + + // On the command line there is no request to describe, so neither of + // these is set. This value only seeds the suggested board URL on the + // form, and a scripted install passes its own boardurl in, so a + // placeholder is enough -- but reading the keys unguarded was a warning + // on every CLI run. + $host = $_SERVER['SERVER_NAME'] ?? 'localhost'; + $port = $_SERVER['SERVER_PORT'] ?? ''; + + return $host . (empty($port) || $port == '80' ? '' : ':' . $port); } /** From 7477a6c37ee14b72f68f1747bb4927f450cc560e Mon Sep 17 00:00:00 2001 From: albertlast Date: Sun, 2 Aug 2026 07:57:51 +0200 Subject: [PATCH 15/26] Skips the browser sign-in when installing from the command line finalize() ends by signing the new administrator in, so the browser that just ran the installer lands on an admin session instead of a login form. It sets a login cookie, then records the session against the user agent that asked for it. None of that has any meaning on the command line. There is no browser to hold the cookie and no user agent to key the session on, so every CLI install ended with four warnings - headers sent after output had already started, a session that could not be started, and an id that could not be regenerated - and then wrote a sessions row built from an undefined HTTP_USER_AGENT. Runs the whole block only when there is a request behind it. The stats that follow it are untouched, so an install still records latestMember, totalMessages and totalTopics either way. Co-Authored-By: Claude Opus 5 Signed-off-by: albertlast --- Sources/Maintenance/Tools/Install.php | 82 +++++++++++++++------------ 1 file changed, 46 insertions(+), 36 deletions(-) diff --git a/Sources/Maintenance/Tools/Install.php b/Sources/Maintenance/Tools/Install.php index a0c2d284a53..73d9d286228 100644 --- a/Sources/Maintenance/Tools/Install.php +++ b/Sources/Maintenance/Tools/Install.php @@ -1202,48 +1202,58 @@ public function finalize(): bool Db::$db->free_result($request); } - // Automatically log them in ;) - if (isset(Maintenance::$context['id_member'], Maintenance::$context['password_salt'])) { - Cookie::setLoginCookie(3153600 * 60, Maintenance::$context['id_member'], Cookie::encrypt($_POST['password1'], Maintenance::$context['password_salt'])); - } + // Sign the new administrator in, so the browser that just ran the + // installer lands on an admin session rather than a login form. + // + // None of that means anything on the command line: there is no browser + // to hold the cookie, and no user agent to record against the session. + // Attempting it anyway sent headers after output had already started and + // left four warnings on every run, then wrote a session row keyed on an + // undefined HTTP_USER_AGENT. + if (!Sapi::isCLI()) { + // Automatically log them in ;) + if (isset(Maintenance::$context['id_member'], Maintenance::$context['password_salt'])) { + Cookie::setLoginCookie(3153600 * 60, Maintenance::$context['id_member'], Cookie::encrypt($_POST['password1'], Maintenance::$context['password_salt'])); + } - $result = Db::$db->query( - 'SELECT value - FROM {db_prefix}settings - WHERE variable = {string:db_sessions}', - [ - 'db_sessions' => 'databaseSession_enable', - 'db_error_skip' => true, - ], - ); + $result = Db::$db->query( + 'SELECT value + FROM {db_prefix}settings + WHERE variable = {string:db_sessions}', + [ + 'db_sessions' => 'databaseSession_enable', + 'db_error_skip' => true, + ], + ); - if (Db::$db->num_rows($result) != 0) { - list($db_sessions) = Db::$db->fetch_row($result); - } - Db::$db->free_result($result); + if (Db::$db->num_rows($result) != 0) { + list($db_sessions) = Db::$db->fetch_row($result); + } + Db::$db->free_result($result); - if (empty($db_sessions)) { - $_SESSION['admin_time'] = time(); - } else { - $_SERVER['HTTP_USER_AGENT'] = substr($_SERVER['HTTP_USER_AGENT'], 0, 211); + if (empty($db_sessions)) { + $_SESSION['admin_time'] = time(); + } else { + $_SERVER['HTTP_USER_AGENT'] = substr($_SERVER['HTTP_USER_AGENT'], 0, 211); - Db::$db->insert( - 'replace', - '{db_prefix}sessions', - [ - 'session_id' => 'string', - 'last_update' => 'int', - 'data' => 'string', - ], - [ + Db::$db->insert( + 'replace', + '{db_prefix}sessions', [ - session_id(), - time(), - 'USER_AGENT|s:' . \strlen($_SERVER['HTTP_USER_AGENT']) . ':"' . $_SERVER['HTTP_USER_AGENT'] . '";admin_time|i:' . time() . ';', + 'session_id' => 'string', + 'last_update' => 'int', + 'data' => 'string', ], - ], - ['session_id'], - ); + [ + [ + session_id(), + time(), + 'USER_AGENT|s:' . \strlen($_SERVER['HTTP_USER_AGENT']) . ':"' . $_SERVER['HTTP_USER_AGENT'] . '";admin_time|i:' . time() . ';', + ], + ], + ['session_id'], + ); + } } Logging::updateStats('member'); From 89324b1d038f9238c8323bca5d78cdd119b20b58 Mon Sep 17 00:00:00 2001 From: albertlast Date: Sun, 2 Aug 2026 07:57:51 +0200 Subject: [PATCH 16/26] Reports the step a maintenance tool actually paused on Two things were wrong with the note the command line prints when a tool stops part way. It indexed the step list to get the number, which counts from zero, while every other line of output uses the step's own id, which counts from one - so it disagreed with the "Step 3: Database Settings" lines immediately above it. It also fired on a successful run. Tools deliberately return false from their last step so the web flow stops and renders its "all done" template, which means reaching that step is success rather than a pause, and a completed install claimed to have stopped at it. Co-Authored-By: Claude Opus 5 Signed-off-by: albertlast --- Sources/Maintenance/Maintenance.php | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/Sources/Maintenance/Maintenance.php b/Sources/Maintenance/Maintenance.php index 1b2a1b2c04f..8c47056f45d 100644 --- a/Sources/Maintenance/Maintenance.php +++ b/Sources/Maintenance/Maintenance.php @@ -835,15 +835,25 @@ public static function exit(bool $fallthrough = false): void exit(1); } - // Nothing went wrong, but we are not finished either: a step wanted - // input it was not given. Say which one, so a script that has to be - // run more than once can tell where it got to. - if (isset(self::$tool) && self::getCurrentStep() <= \count(self::$tool->getSteps())) { + // Nothing went wrong, but we may not be finished either: a step can + // stop because it wanted input it was not given. Say which one, so + // a script that has to be run more than once can tell where it got + // to. The step numbers its own id from one, which is what every + // other line of output uses. + // + // The last step is excluded on purpose. Tools end by returning false + // from it so that the web flow stops and renders its "all done" + // template, which means reaching it is success, not a pause. + $steps = isset(self::$tool) ? self::$tool->getSteps() : []; + + if (isset($steps[self::getCurrentStep()]) && self::getCurrentStep() < \count($steps) - 1) { + $stopped = $steps[self::getCurrentStep()]; + fwrite( STDERR, - 'stopped at step ' . self::getCurrentStep() - . ' of ' . \count(self::$tool->getSteps()) - . ' (' . (self::$tool->getSteps()[self::getCurrentStep()]?->getName() ?? 'unknown') . ")\n", + 'stopped at step ' . $stopped->getId() + . ' of ' . \count($steps) + . ' (' . $stopped->getName() . ")\n", ); } } From 7665045176b5aa7987dc16480f031b64398891cf Mon Sep 17 00:00:00 2001 From: albertlast Date: Sun, 2 Aug 2026 07:58:06 +0200 Subject: [PATCH 17/26] Installs the forum from the command line The dev environment stopped at a Settings.php and a staged install.php, leaving the actual install to a human clicking through a browser. That is the one step between a fresh clone and a running forum that could not be scripted, and everything that wants to test against a real install has to start by doing it. Adds four scripts under .docker/: install-forum.sh installs a forum, no browser involved use-engine.sh switches which installed forum is live reset.sh empties one engine's database and restages lib.sh shared settings and engine name normalisation The installer is already CLI-native - parseCliArguments() turns --name=value into $_POST and execute() runs every step in one process - so this is two passes rather than 2.1's five curl requests. The second pass carries pop_done, which is the short-circuit past the population report; passing it on the first pass would skip building the schema. --engine both installs MySQL and then PostgreSQL. It has to be sequential: Settings.php pins a single db_type and Db::load() hands back the connection it already made, so only one engine is ever live in a process. Both installs are kept, and use-engine.sh swaps between them by putting the saved Settings.php back - no restart, because the entrypoint only writes one when there is not one already. --pin-secrets fixes auth_secret and image_proxy_secret, which are generated with random_bytes() and stored nowhere but Settings.php. Without it the two installs differ by more than their database and a login cookie does not survive the switch. The cookie name needs no such help: createCookieName() is a crc32 of the database name and prefix. Co-Authored-By: Claude Opus 5 Signed-off-by: albertlast --- .docker/README.md | 55 +++++++++++- .docker/install-forum.sh | 187 +++++++++++++++++++++++++++++++++++++++ .docker/lib.sh | 123 +++++++++++++++++++++++++ .docker/reset.sh | 99 +++++++++++++++++++++ .docker/use-engine.sh | 48 ++++++++++ .gitignore | 4 + 6 files changed, 514 insertions(+), 2 deletions(-) create mode 100644 .docker/install-forum.sh create mode 100644 .docker/lib.sh create mode 100644 .docker/reset.sh create mode 100644 .docker/use-engine.sh diff --git a/.docker/README.md b/.docker/README.md index 2ba2f277fb3..111bef70986 100644 --- a/.docker/README.md +++ b/.docker/README.md @@ -53,6 +53,57 @@ forum. ## Installing the forum +```sh +.docker/install-forum.sh --engine mysql +.docker/install-forum.sh --engine postgresql +.docker/install-forum.sh --engine both +``` + +That resets the engine's database and installs a forum into it, with no browser +involved. It takes about a minute. Log in at http://localhost:8080 as +`admin` / `password`. + +SMF 3.0's installer is CLI-native: `Maintenance::parseCliArguments()` turns +`--name=value` into `$_POST`, and `Maintenance::execute()` then runs every step +in one process, stopping at the first that still needs input. The script makes +two passes, because `databasePopulation()` always stops the first time even +though it succeeded — it pauses so a human can read its "N duplicate tables +ignored" report, and the form's `pop_done` field is the short-circuit past it. +Passing `pop_done` on the first pass would skip building the schema entirely. + +Two flags worth knowing: + +- `--force` reinstalls even when a forum is already there. Without it the + script leaves an existing install alone. +- `--pin-secrets` fixes `auth_secret` and `image_proxy_secret` to known values + instead of the random ones `ForumSettings()` generates. Both installs then + differ only in their database, so a login cookie survives `use-engine.sh`. + Dev-only values for a throwaway forum: never reuse them. + +### Two forums at once + +`--engine both` installs MySQL first and PostgreSQL second, one after the other. +It has to be sequential: `Settings.php` pins a single `$db_type`, and +`Db::load()` hands back the connection it already made, so only one engine can +ever be live in a process. + +Both installs are kept. Switch between them with: + +```sh +.docker/use-engine.sh postgresql +``` + +That puts the saved `Settings.php` back and clears `cache/`. No restart is +needed — the entrypoint only writes `Settings.php` when there is not one, so it +leaves whatever is in place alone. The copies live in `.docker/settings/` and +are gitignored. + +`reset.sh` is the other half: it empties one engine's database and restages the +installer, discarding that forum. `use-engine.sh` switches between forums, +`reset.sh` throws one away. + +### Installing in a browser instead + On first boot the entrypoint writes a `Settings.php` pre-filled for the chosen engine and copies `other/install.php` to the web root, so http://localhost:8080 redirects into the installer. @@ -102,8 +153,8 @@ The repository is bind-mounted at `/var/www/html`, so edits on the host are live on the next request. Opcache is on but revalidates every request, so you never need to restart for a PHP change. -To reinstall from scratch: `docker compose down -v`, delete `Settings.php` and -`Settings_bak.php`, then `docker compose up -d`. +To reinstall from scratch: `.docker/install-forum.sh --engine mysql --force`. +To wipe everything including the volumes: `docker compose down -v`. ## Debugging SQL with the PostgreSQL log diff --git a/.docker/install-forum.sh b/.docker/install-forum.sh new file mode 100644 index 00000000000..613b604c90a --- /dev/null +++ b/.docker/install-forum.sh @@ -0,0 +1,187 @@ +#!/usr/bin/env bash +# Installs the forum without a browser. +# +# .docker/install-forum.sh --engine mysql +# .docker/install-forum.sh --engine postgresql +# .docker/install-forum.sh --engine both +# +# SMF 3.0's installer is CLI-native: Maintenance::parseCliArguments() turns +# --name=value into $_POST, and Maintenance::execute() then runs every step in +# one process, stopping at the first that still needs input. So unlike 2.1, +# which needs a five-request curl driver, this is two invocations: +# +# pass 1 Welcome -> Writable -> Database settings -> Forum settings +# -> Database population, which builds the schema and then stops +# pass 2 the same again, plus --pop_done, which walks straight past the +# population report into the admin account and finalise +# +# databasePopulation() always stops the first time even though it succeeded: it +# pauses so a human can read its "N duplicate tables ignored" report, and the +# form's pop_done field is the short-circuit that skips it. Passing pop_done on +# pass 1 would skip building the schema altogether, which is why this is two +# passes and not one. +# +# Every step re-runs on pass 2. They are all idempotent given the same input -- +# the settings steps rewrite the same values, and adminAccount() stops if an +# administrator already exists. +# +# Runs on the host. +set -euo pipefail + +. "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh" + +ENGINE='' +PIN_SECRETS=0 +FORCE=0 + +while [ $# -gt 0 ]; do + case "$1" in + --engine) ENGINE="$2"; shift 2 ;; + --engine=*) ENGINE="${1#*=}"; shift ;; + --pin-secrets) PIN_SECRETS=1; shift ;; + --force) FORCE=1; shift ;; + -h|--help) sed -n '2,27p' "${BASH_SOURCE[0]}"; exit 0 ;; + *) die "unknown argument: $1" ;; + esac +done + +[ -n "$ENGINE" ] || die 'need --engine mysql|postgresql|both' +ENGINES=$(engine_list "$ENGINE") || die "unknown engine: $ENGINE" + +cd "$BOARD_DIR" + +# The installer's own name for each engine, which is the key of the array it +# builds from the drivers it found. These are capitalised, and a lowercase +# db_type is rejected outright -- so they are spelled exactly as the installer +# spells them rather than reusing the SMF type. +installer_db_type() { + case "$1" in + mysql) echo 'MySQL' ;; + postgresql) echo 'PostgreSQL' ;; + *) return 1 ;; + esac +} + +install_one() { + local smf_type="$1" db_type server port args + + db_type=$(installer_db_type "$smf_type") + server=$(engine_server "$smf_type") + port=$(engine_port "$smf_type") + + if [ "$FORCE" -eq 0 ] && [ -n "$(installed_version "$smf_type" || true)" ]; then + log "${smf_type}: already installed (SMF $(installed_version "$smf_type")), nothing to do" + + return 0 + fi + + log "${smf_type}: resetting" + "$DOCKER_DIR/reset.sh" --engine "$smf_type" >/dev/null + + args=( + --contbutt=1 + --db_type="$db_type" + --db_server="$server" + --db_port="$port" + --db_name="$DB_NAME" + --db_user="$DB_USER" + --db_passwd="$DB_PASSWORD" + --db_prefix="$DB_PREFIX" + --boardurl="$SMF_BOARDURL" + --mbname="$SMF_MBNAME" + --username="$SMF_ADMIN_USER" + --email="$SMF_ADMIN_EMAIL" + --server_email="$SMF_ADMIN_EMAIL" + --password1="$SMF_ADMIN_PASS" + --password2="$SMF_ADMIN_PASS" + ) + + log "${smf_type}: building the schema" + docker compose exec -T web php install.php "${args[@]}" >/dev/null + + log "${smf_type}: creating the administrator and finalising" + docker compose exec -T web php install.php "${args[@]}" --pop_done=1 >/dev/null + + local version + version=$(installed_version "$smf_type" || true) + + [ -n "$version" ] || die "${smf_type}: the installer finished but the forum is not installed" + + log "${smf_type}: installed SMF ${version}" + + if [ "$PIN_SECRETS" -eq 1 ]; then + pin_secrets + fi + + save_settings "$smf_type" +} + +# ForumSettings() generates auth_secret and image_proxy_secret with +# random_bytes() and stores them nowhere but Settings.php, so the two engines +# end up with different ones and a login cookie stops being valid the moment +# use-engine.sh switches. Pinning them leaves the database as the only thing +# that differs between the two installs. +# +# The cookie name needs no such help: createCookieName() is a crc32 of the +# database name and prefix, which are the same on both. +# +# Dev-only values for a throwaway forum, published here deliberately. Never +# reuse them anywhere real. +pin_secrets() { + log 'pinning auth_secret and image_proxy_secret' + + # The values have to be handed over with -e. Exporting them on the host does + # nothing: docker compose exec starts a fresh environment, so getenv() came + # back empty and this wrote two empty secrets over the generated ones. + docker compose exec -T \ + -e PIN_AUTH_SECRET="$PIN_AUTH_SECRET" \ + -e PIN_IMAGE_PROXY_SECRET="$PIN_IMAGE_PROXY_SECRET" \ + web php -r ' + define("SMF", 1); + define("SMF_SETTINGS_FILE", "/var/www/html/Settings.php"); + define("SMF_SETTINGS_BACKUP_FILE", "/var/www/html/Settings_bak.php"); + require_once "/var/www/html/index.php"; + + $auth = (string) getenv("PIN_AUTH_SECRET"); + $proxy = (string) getenv("PIN_IMAGE_PROXY_SECRET"); + + if ($auth === "" || $proxy === "") { + fwrite(STDERR, "pin-secrets: the secrets did not reach the container\n"); + exit(1); + } + + exit(SMF\Config::updateSettingsFile([ + "auth_secret" => $auth, + "image_proxy_secret" => $proxy, + ]) ? 0 : 1); + ' >/dev/null +} + +# Keep each engine's Settings.php so use-engine.sh can put it back without a +# reinstall. Gitignored: generated secrets and a machine-specific board URL. +save_settings() { + local smf_type="$1" + + mkdir -p "$SETTINGS_DIR" + cp Settings.php "$SETTINGS_DIR/Settings.${smf_type}.php" + cp Settings_bak.php "$SETTINGS_DIR/Settings_bak.${smf_type}.php" + + log "${smf_type}: settings saved to .docker/settings/" +} + +PIN_AUTH_SECRET="${PIN_AUTH_SECRET:-0b6e5f3c1a94d27e8f5b0c3a76d1e94f2b8c5a03e7d146f9b2c8a501d3e7f4c69}" +PIN_IMAGE_PROXY_SECRET="${PIN_IMAGE_PROXY_SECRET:-7f2a9c4e0b6d18a35c92}" + +# Sequential on purpose. Settings.php pins one $db_type and Db::load() returns +# the connection it already made, so only one engine can be live at a time -- +# "both" is a chain, never two connections. +for smf_type in $ENGINES; do + install_one "$smf_type" +done + +# Leave the first engine of a "both" run active rather than whichever happened +# to go last, so the result does not depend on the order. +FIRST_ENGINE="${ENGINES%% *}" +"$DOCKER_DIR/use-engine.sh" "$FIRST_ENGINE" >/dev/null + +log "active engine: ${FIRST_ENGINE} -- ${SMF_BOARDURL} (${SMF_ADMIN_USER} / ${SMF_ADMIN_PASS})" diff --git a/.docker/lib.sh b/.docker/lib.sh new file mode 100644 index 00000000000..5c13d410080 --- /dev/null +++ b/.docker/lib.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +# Shared settings and helpers for the .docker scripts. Sourced, never run. +# +# Host-side scripts (reset.sh, install-forum.sh, use-engine.sh) source this from +# wherever the caller happens to be standing; everything below resolves paths +# for itself rather than assuming a working directory. +# +# Everything defined here is consumed by the scripts that source this file, and +# a linter reading it on its own cannot see any of those uses -- hence the +# blanket disable below. Keep it on its own, with nothing after it that starts +# with the linter's name, or the following line gets parsed as a directive too. +# +# shellcheck disable=SC2034 + +# Git Bash on Windows rewrites anything that looks like a Unix path before +# handing it to a program, so a container-side path like /var/www/html/... is +# silently turned into C:/Program Files/Git/var/www/html/... and the command +# fails with "Could not open input file". These two switch that off. They mean +# nothing on Linux and macOS. +export MSYS_NO_PATHCONV=1 +export MSYS2_ARG_CONV_EXCL='*' + +# Repository root, regardless of where the caller was standing. +DOCKER_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +BOARD_DIR=$(cd -- "$DOCKER_DIR/.." && pwd) + +# Where use-engine.sh keeps each engine's Settings.php. Gitignored: these hold +# generated secrets and a machine-specific board URL. +SETTINGS_DIR="$DOCKER_DIR/settings" + +# ---------------------------------------------------------------- credentials +# These match compose.yaml's defaults. Override them in the environment if you +# changed them in .env. +DB_NAME="${DB_NAME:-smf}" +DB_USER="${DB_USER:-smf}" +DB_PASSWORD="${DB_PASSWORD:-smf}" +DB_ROOT_PASSWORD="${DB_ROOT_PASSWORD:-smf}" +DB_PREFIX="${DB_PREFIX:-smf_}" + +WEB_PORT="${WEB_PORT:-8080}" +SMF_BOARDURL="${SMF_BOARDURL:-http://localhost:${WEB_PORT}}" +SMF_MBNAME="${SMF_MBNAME:-SMF Dev}" + +# The administrator the installer creates. Dev-only values for a throwaway +# forum; never reuse them anywhere real. +SMF_ADMIN_USER="${SMF_ADMIN_USER:-admin}" +SMF_ADMIN_PASS="${SMF_ADMIN_PASS:-password}" +# example.com is reserved by RFC 2606, so this can never reach a real inbox. +# SMF's validator rejects dotless domains, so 'admin@localhost' is not an option. +SMF_ADMIN_EMAIL="${SMF_ADMIN_EMAIL:-admin@example.com}" + +# --------------------------------------------------------------------- output +log() { printf '[smf-dev] %s\n' "$*"; } +warn() { printf '[smf-dev] %s\n' "$*" >&2; } +die() { printf '[smf-dev] error: %s\n' "$*" >&2; exit 1; } + +# Engine name normalisation. Everything downstream uses either the SMF type +# ('mysql' / 'postgresql') or the compose service name ('mysql' / 'postgres'), +# and mixing them up is an easy way to waste an afternoon. +engine_smf_type() { + case "$1" in + mysql|mysqli|mariadb) echo 'mysql' ;; + postgres|postgresql|pgsql) echo 'postgresql' ;; + *) return 1 ;; + esac +} + +engine_service() { + case "$1" in + mysql|mysqli|mariadb) echo 'mysql' ;; + postgres|postgresql|pgsql) echo 'postgres' ;; + *) return 1 ;; + esac +} + +# Container-internal host and port for an engine. Not the host-side ports in +# compose.yaml: these are what Settings.php has to contain. +engine_server() { + case "$(engine_smf_type "$1")" in + mysql) echo "${SMF_MYSQL_SERVER:-mysql}" ;; + postgresql) echo "${SMF_POSTGRES_SERVER:-postgres}" ;; + *) return 1 ;; + esac +} + +engine_port() { + case "$(engine_smf_type "$1")" in + mysql) echo "${SMF_MYSQL_PORT:-3306}" ;; + postgresql) echo "${SMF_POSTGRES_PORT:-5432}" ;; + *) return 1 ;; + esac +} + +# Expands "both" into the engines to act on, in the order they run. Only one +# engine can be live at a time -- Settings.php pins $db_type and Db::load() +# early-returns once the connection exists -- so "both" is a sequential chain, +# never two connections. +engine_list() { + case "$1" in + both|all) echo 'mysql postgresql' ;; + *) engine_smf_type "$1" ;; + esac +} + +# The installed version for one engine, empty if the forum is not installed. +# Asks the database directly rather than trusting the presence of a file: +# Settings.php exists from the moment the entrypoint writes it, long before +# there is a forum behind it. +installed_version() { + local engine service + engine=$(engine_smf_type "$1") || return 1 + service=$(engine_service "$1") + + if [ "$engine" = 'mysql' ]; then + docker compose exec -T -e MYSQL_PWD="$DB_PASSWORD" "$service" \ + mysql -u"$DB_USER" -D "$DB_NAME" -N -B -e \ + "SELECT value FROM ${DB_PREFIX}settings WHERE variable = 'smfVersion';" 2>/dev/null + else + docker compose exec -T "$service" \ + psql -U "$DB_USER" -d "$DB_NAME" -tAX -c \ + "SELECT value FROM ${DB_PREFIX}settings WHERE variable = 'smfVersion';" 2>/dev/null + fi +} diff --git a/.docker/reset.sh b/.docker/reset.sh new file mode 100644 index 00000000000..00f2de5a404 --- /dev/null +++ b/.docker/reset.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# Returns the stack to "installable": no forum, an empty database for the chosen +# engine, and a Settings.php regenerated for it. +# +# .docker/reset.sh --engine mysql +# .docker/reset.sh --engine postgresql +# +# This is also how you move an install between engines. Settings.php pins one +# engine and wins over SMF_DB_TYPE, so switching means throwing it away and +# letting the entrypoint write a new one. To keep an install rather than +# discard it, use use-engine.sh instead. +# +# Only the chosen engine's database is touched. The two engines keep separate +# volumes, so a MySQL reset can never disturb a PostgreSQL install or vice +# versa. +# +# Runs on the host. +set -euo pipefail + +. "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh" + +ENGINE='' +KEEP_FILES=0 + +while [ $# -gt 0 ]; do + case "$1" in + --engine) ENGINE="$2"; shift 2 ;; + --engine=*) ENGINE="${1#*=}"; shift ;; + --keep-files) KEEP_FILES=1; shift ;; + -h|--help) sed -n '2,17p' "${BASH_SOURCE[0]}"; exit 0 ;; + *) die "unknown argument: $1" ;; + esac +done + +[ -n "$ENGINE" ] || die 'need --engine mysql|postgresql' +SERVICE=$(engine_service "$ENGINE") || die "unknown engine: $ENGINE" +SMF_TYPE=$(engine_smf_type "$ENGINE") + +cd "$BOARD_DIR" + +log "resetting for ${SMF_TYPE}" + +# ------------------------------------------------------------------ the forum +# Stop the web container first: Apache holding a half-installed forum open while +# its database vanishes underneath produces confusing errors in the log. +docker compose stop web >/dev/null 2>&1 || true + +rm -f Settings.php Settings_bak.php install.php upgrade.php + +# SMF's cache holds a serialised copy of $modSettings, which would otherwise +# outlive the database it describes. +find cache -type f ! -name 'index.php' ! -name '.htaccess' -delete 2>/dev/null || true + +if [ "$KEEP_FILES" -eq 0 ]; then + for dir in attachments custom_avatar; do + find "$dir" -type f ! -name 'index.php' ! -name '.htaccess' ! -name 'blank.png' -delete 2>/dev/null || true + done + rm -f Packages/installed.list +fi + +# --------------------------------------------------------------- the database +docker compose up -d "$SERVICE" >/dev/null + +if [ "$SMF_TYPE" = 'mysql' ]; then + # As root: the smf user has rights on the smf database but cannot drop and + # recreate it. utf8mb4 matches what compose.yaml asks the server for and + # what SMF's own DDL emits. + docker compose exec -T -e MYSQL_PWD="$DB_ROOT_PASSWORD" "$SERVICE" mysql -uroot -e " + DROP DATABASE IF EXISTS \`${DB_NAME}\`; + CREATE DATABASE \`${DB_NAME}\` CHARACTER SET utf8mb4; + GRANT ALL ON \`${DB_NAME}\`.* TO '${DB_USER}'@'%'; + " +else + # The database itself cannot be dropped while we are connected to it, and + # dropping the schema is enough: it takes the tables, sequences, functions + # and operators with it. smf owns the database, so it may recreate public. + docker compose exec -T "$SERVICE" psql -v ON_ERROR_STOP=1 -q -U "$DB_USER" -d "$DB_NAME" -c ' + DROP SCHEMA IF EXISTS public CASCADE; + CREATE SCHEMA public; + ' >/dev/null +fi + +log "${SMF_TYPE} database ${DB_NAME} is empty" + +# Bring web back up so the entrypoint regenerates Settings.php for this engine +# and stages the installer. +SMF_DB_TYPE="$SMF_TYPE" docker compose up -d web >/dev/null + +# The entrypoint waits for the database before it writes anything, so give it a +# moment to get there rather than racing whatever runs next. +for _ in $(seq 1 60); do + if docker compose exec -T web test -f install.php 2>/dev/null; then + log 'installer staged, ready to install' + exit 0 + fi + sleep 1 +done + +die 'timed out waiting for the entrypoint to stage install.php (docker compose logs web)' diff --git a/.docker/use-engine.sh b/.docker/use-engine.sh new file mode 100644 index 00000000000..5fd2b7a96b5 --- /dev/null +++ b/.docker/use-engine.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Switches which installed forum is live, without reinstalling either. +# +# .docker/use-engine.sh mysql +# .docker/use-engine.sh postgresql +# +# Both database services always run, on separate volumes, so each keeps its own +# forum. What decides which one you get is Settings.php: it pins $db_type, and +# it wins over SMF_DB_TYPE. install-forum.sh files a copy per engine, and this +# puts one of them back. +# +# No container restart is needed. The entrypoint only writes Settings.php when +# there is not one, so it leaves whatever is in place alone. +# +# To throw an install away and start over, use reset.sh instead. +# +# Runs on the host. +set -euo pipefail + +. "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh" + +[ $# -eq 1 ] || die 'usage: use-engine.sh mysql|postgresql' + +case "$1" in + -h|--help) sed -n '2,16p' "${BASH_SOURCE[0]}"; exit 0 ;; +esac + +SMF_TYPE=$(engine_smf_type "$1") || die "unknown engine: $1" +SAVED="$SETTINGS_DIR/Settings.${SMF_TYPE}.php" + +cd "$BOARD_DIR" + +[ -f "$SAVED" ] || die "no saved settings for ${SMF_TYPE} -- run .docker/install-forum.sh --engine ${SMF_TYPE}" + +cp "$SAVED" Settings.php +cp "$SETTINGS_DIR/Settings_bak.${SMF_TYPE}.php" Settings_bak.php + +# SMF's cache holds a serialised copy of $modSettings, which describes the +# database we are switching away from. $cache_enable defaults to 0, so there is +# usually nothing there -- but the directory also holds db_last_error.php and +# the generated CSS and JS, and clearing it costs nothing. +find cache -type f ! -name 'index.php' ! -name '.htaccess' -delete 2>/dev/null || true + +VERSION=$(installed_version "$SMF_TYPE" || true) + +[ -n "$VERSION" ] || warn "${SMF_TYPE} has no forum installed -- Settings.php now points at an empty database" + +log "active engine: ${SMF_TYPE}${VERSION:+ (SMF ${VERSION})} -- ${SMF_BOARDURL}" diff --git a/.gitignore b/.gitignore index 6b46b9c3a5c..7bcc6bba3a0 100644 --- a/.gitignore +++ b/.gitignore @@ -78,6 +78,10 @@ Thumbs.db /.env /compose.override.yaml /compose.override.yml +# One saved Settings.php per engine, so use-engine.sh can switch between two +# installs without reinstalling. Generated secrets and a machine-specific +# board URL: local to whoever ran the installer. +/.docker/settings/ # Test / Private files # ######################## From 7876ac1fcaaa91ec74da2353967f40ecc83336f4 Mon Sep 17 00:00:00 2001 From: albertlast Date: Sun, 2 Aug 2026 07:58:28 +0200 Subject: [PATCH 18/26] Marks the dev environment scripts executable The README invokes them as .docker/install-forum.sh rather than through bash, which only works with the bit set. Windows checkouts do not carry it, so it has to be recorded in the index. lib.sh is left alone: it is sourced, never run. Co-Authored-By: Claude Opus 5 Signed-off-by: albertlast --- .docker/install-forum.sh | 0 .docker/reset.sh | 0 .docker/use-engine.sh | 0 3 files changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 .docker/install-forum.sh mode change 100644 => 100755 .docker/reset.sh mode change 100644 => 100755 .docker/use-engine.sh diff --git a/.docker/install-forum.sh b/.docker/install-forum.sh old mode 100644 new mode 100755 diff --git a/.docker/reset.sh b/.docker/reset.sh old mode 100644 new mode 100755 diff --git a/.docker/use-engine.sh b/.docker/use-engine.sh old mode 100644 new mode 100755 From b79d33ca2a6be37a043d39613f91318006e07d4c Mon Sep 17 00:00:00 2001 From: albertlast Date: Sun, 2 Aug 2026 08:18:55 +0200 Subject: [PATCH 19/26] Adds an integration suite that runs against a real forum The unit suite is deliberately database-free, and says so: its bootstrap notes that anything reaching Config::$modSettings, User::$me or Db::$db "belongs in an integration suite running against a real install". There was not one, so most of the forum had no automated proof of anything. Adds tests/Integration/ as a second PHPUnit testsuite, and .docker/test.sh to run it on one engine or both. composer test still runs everything; when there is no forum to talk to the integration tests skip rather than fail, so it stays useful without Docker. IntegrationTestCase gives each test a transaction that is rolled back afterwards, actingAs()/adminId() via User::setMe(), hook() registration that lives only in $modSettings, and assertNoErrorsLogged() - which is usually the point of the test, because SMF records most of what goes wrong in log_errors rather than showing it. Three tests to start: HarnessTest checks the harness itself, including that the rollback really happens and that assertNoErrorsLogged can fail ModSettingsTest the counter regression: updateModSettings($x, true) emitted SET value = value + 1 against a text column SchemaTest compares Sources/Db/Schema/v3_0/ against the database in both directions, which is the drift AGENTS.md warns only ever shows up at runtime ModSettingsTest is why running both engines matters rather than being tidy: with the fix reverted it still passes on MySQL, which coerces text to a number, and fails only on PostgreSQL, which refuses. Verified in both directions before committing. Co-Authored-By: Claude Opus 5 Signed-off-by: albertlast --- .docker/README.md | 21 ++ .docker/test.sh | 81 +++++++ AGENTS.md | 39 ++++ composer.json | 2 + phpunit.xml.dist | 8 + tests/Integration/HarnessTest.php | 132 +++++++++++ tests/Integration/Installation.php | 111 +++++++++ tests/Integration/IntegrationTestCase.php | 270 ++++++++++++++++++++++ tests/Integration/ModSettingsTest.php | 116 ++++++++++ tests/Integration/SchemaTest.php | 100 ++++++++ tests/Integration/index.php | 8 + tests/bootstrap.php | 6 + 12 files changed, 894 insertions(+) create mode 100755 .docker/test.sh create mode 100644 tests/Integration/HarnessTest.php create mode 100644 tests/Integration/Installation.php create mode 100644 tests/Integration/IntegrationTestCase.php create mode 100644 tests/Integration/ModSettingsTest.php create mode 100644 tests/Integration/SchemaTest.php create mode 100644 tests/Integration/index.php diff --git a/.docker/README.md b/.docker/README.md index 111bef70986..72d613ac847 100644 --- a/.docker/README.md +++ b/.docker/README.md @@ -102,6 +102,27 @@ are gitignored. installer, discarding that forum. `use-engine.sh` switches between forums, `reset.sh` throws one away. +## Running the tests + +```sh +.docker/test.sh # both engines +.docker/test.sh --engine postgresql +.docker/test.sh --engine both --filter ModSettings +``` + +Anything it does not recognise is passed on to PHPUnit. It installs a forum for +an engine that has not got one, and puts the previously active engine back when +it finishes. + +Running on both is the point rather than a thoroughness exercise. The counter +regression in `tests/Integration/ModSettingsTest.php` **passes on MySQL with the +bug still in place** and only fails on PostgreSQL, because MySQL coerces text to +a number where PostgreSQL refuses. A suite that only ever sees one engine proves +considerably less than it looks like it does. + +The unit suite needs none of this — `composer test` runs everything, and the +integration tests skip themselves when there is no forum to talk to. + ### Installing in a browser instead On first boot the entrypoint writes a `Settings.php` pre-filled for the chosen diff --git a/.docker/test.sh b/.docker/test.sh new file mode 100755 index 00000000000..7ea104629e9 --- /dev/null +++ b/.docker/test.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Runs the test suite against a real forum, on one engine or on both. +# +# .docker/test.sh both engines, whole suite +# .docker/test.sh --engine postgresql +# .docker/test.sh --engine both --filter ModSettings +# +# Anything after the recognised options is handed straight to PHPUnit, so +# --filter, --testsuite and friends work as usual. +# +# Installs a forum for an engine that has not got one yet. Use +# .docker/install-forum.sh --force to start any of them over. +# +# Running on both engines is the point rather than a thoroughness exercise: the +# two disagree often enough that a suite which only ever sees one of them +# proves considerably less than it appears to. The counter regression in +# tests/Integration/ModSettingsTest.php passes on MySQL with the bug still in +# place, and fails on PostgreSQL. +# +# Runs on the host. +set -euo pipefail + +. "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh" + +ENGINE='both' +PHPUNIT_ARGS=() + +while [ $# -gt 0 ]; do + case "$1" in + --engine) ENGINE="$2"; shift 2 ;; + --engine=*) ENGINE="${1#*=}"; shift ;; + -h|--help) sed -n '2,19p' "${BASH_SOURCE[0]}"; exit 0 ;; + *) PHPUNIT_ARGS+=("$1"); shift ;; + esac +done + +ENGINES=$(engine_list "$ENGINE") || die "unknown engine: $ENGINE" + +cd "$BOARD_DIR" + +# Remember what was active, and put it back afterwards however this ends. A test +# run should not silently leave the forum pointed somewhere else. +ORIGINAL='' + +if [ -f Settings.php ]; then + ORIGINAL=$(sed -n "s|^\$db_type = '\([^']*\)';.*|\1|p" Settings.php | head -n 1 | tr '[:upper:]' '[:lower:]') +fi + +restore_engine() { + if [ -n "$ORIGINAL" ] && [ -f "$SETTINGS_DIR/Settings.$(engine_smf_type "$ORIGINAL").php" ]; then + "$DOCKER_DIR/use-engine.sh" "$ORIGINAL" >/dev/null 2>&1 || true + fi +} + +trap restore_engine EXIT + +FAILED='' + +for smf_type in $ENGINES; do + if [ -z "$(installed_version "$smf_type" || true)" ]; then + log "${smf_type}: no forum yet, installing one" + "$DOCKER_DIR/install-forum.sh" --engine "$smf_type" >/dev/null + fi + + "$DOCKER_DIR/use-engine.sh" "$smf_type" >/dev/null + + log "${smf_type}: running the tests" + + if docker compose exec -T web vendor/bin/phpunit --no-coverage --colors=always "${PHPUNIT_ARGS[@]+"${PHPUNIT_ARGS[@]}"}"; then + log "${smf_type}: passed" + else + warn "${smf_type}: FAILED" + FAILED="${FAILED} ${smf_type}" + fi +done + +if [ -n "$FAILED" ]; then + die "failed on:${FAILED}" +fi + +log "passed on: ${ENGINES}" diff --git a/AGENTS.md b/AGENTS.md index e58a652d280..1cf301db383 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -125,6 +125,7 @@ worked example in `tests/Unit/`: #### When it is not - Anything calling `Db::$db` — there is no connection, and faking one is not worth it. + This belongs in the integration suite below. - Anything reading `User::$me`, the session, `$_GET`/`$_POST`/`$_SERVER`, or expecting a loaded theme or `Utils::$context`. - Anything that emits output or sends headers. `beStrictAboutOutputDuringTests` is on, so @@ -133,6 +134,44 @@ worked example in `tests/Unit/`: `failOnRisky` and `failOnWarning` are on as well: a test that asserts nothing is a failure, not a pass. +### The integration suite + +`tests/Integration/` runs against a forum that is actually installed, so it reaches the +things above: `Db::$db`, `Config::$modSettings` as the database holds it, and `User::$me`. + +```bash +.docker/test.sh # both engines +.docker/test.sh --engine postgresql +``` + +`composer test` still runs everything. When there is no forum to talk to the integration +tests **skip** rather than fail, so it stays useful on a machine with no Docker. To get +one: `.docker/install-forum.sh --engine mysql`. + +Extend `SMF\Tests\Integration\IntegrationTestCase`, which gives you: + +- a transaction per test, rolled back afterwards, so tests do not have to order + themselves around each other; +- `actingAs($id)` and `adminId()` for a current user, via `User::setMe()` — the same seam + `Login2::DoLogin()` uses; +- `hook($name, $function)`, registered in `$modSettings` only, so it disappears with the + rollback; +- `assertNoErrorsLogged()`, which is usually the most valuable line in the test: SMF + records most of what goes wrong in `log_errors` rather than showing it, so a page that + returned the right thing while quietly logging an undefined index has still regressed; +- `queryRow()` and `rawSetting()`, which read past `$modSettings` and its cache and fail + with a readable message instead of a `TypeError` when a query fails. + +Two things the rollback does not cover: **DDL**, since MySQL commits implicitly on +`CREATE`/`ALTER`/`DROP`; and anything happening in another process, such as a request made +over HTTP, which runs on its own connection. + +**Run both engines.** This is not thoroughness for its own sake — the two disagree often +enough to matter. `ModSettingsTest` pins a bug that *passes on MySQL with the bug still +in place*, because MySQL silently coerces text to a number where PostgreSQL refuses. +On PostgreSQL a failed query also poisons the rest of the transaction, so one swallowed +error turns every later query in the test into `false`. + #### Writing one `tests/Unit/Test.php`, namespace `SMF\Tests\Unit`, `declare(strict_types=1)`, diff --git a/composer.json b/composer.json index e6585abe13e..0568f6430bf 100644 --- a/composer.json +++ b/composer.json @@ -21,6 +21,8 @@ }, "scripts": { "test": "phpunit --no-coverage", + "test-unit": "phpunit --no-coverage --testsuite unit", + "test-integration": "phpunit --no-coverage --testsuite integration", "lint":"php-cs-fixer --quiet check --config .php-cs-fixer.dist.php --path-mode=intersection $(git diff --name-only \"*.php\") --allow-risky=yes || php-cs-fixer check --diff --config .php-cs-fixer.dist.php --path-mode=intersection $(git diff --name-only \"*.php\") --allow-risky=yes", "lint-fix": "php-cs-fixer fix -v --config .php-cs-fixer.dist.php --path-mode=intersection $(git diff --name-only \"*.php\") --allow-risky=yes", "post-install-cmd": "php ./vendor/simplemachines/build-tools/secure-vendor-dir.php", diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 951db14dd10..720240fd3c8 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -11,6 +11,14 @@ tests/Unit + + + tests/Integration + diff --git a/tests/Integration/HarnessTest.php b/tests/Integration/HarnessTest.php new file mode 100644 index 00000000000..894aaae79c5 --- /dev/null +++ b/tests/Integration/HarnessTest.php @@ -0,0 +1,132 @@ +assertNotEmpty(Config::$modSettings['smfVersion']); + $this->assertSame(SMF_VERSION, Config::$modSettings['smfVersion']); + } + + public function testTheEngineIsOneSmfSupports(): void + { + $this->assertContains( + strtolower(Config::$db_type), + ['mysql', 'postgresql'], + 'Settings.php names an engine this suite does not know about', + ); + } + + /** + * Writes a row the next test then looks for. Together with the test below, + * this is what proves the rollback in tearDown() is real. + */ + public function testAWriteIsVisibleInsideTheTestThatMadeIt(): void + { + Db::$db->insert( + 'replace', + '{db_prefix}settings', + ['variable' => 'string', 'value' => 'string'], + [[self::LEFTOVER, 'written']], + ['variable'], + ); + + $this->assertSame('written', $this->rawSetting(self::LEFTOVER)); + } + + #[Depends('testAWriteIsVisibleInsideTheTestThatMadeIt')] + public function testThatWriteIsGoneByTheNextTest(): void + { + $this->assertNull( + $this->rawSetting(self::LEFTOVER), + 'the previous test\'s write survived, so tests are not isolated', + ); + } + + public function testModSettingsIsRestoredEvenThoughItIsAStaticArray(): void + { + // The rollback returns the table, not the copy in memory. tearDown() has + // to put that back by hand, and this is the check that it does. + Config::$modSettings['smf_tests_in_memory_only'] = 'x'; + + $this->assertArrayHasKey('smf_tests_in_memory_only', Config::$modSettings); + } + + #[Depends('testModSettingsIsRestoredEvenThoughItIsAStaticArray')] + public function testModSettingsHasNoLeftoversFromTheLastTest(): void + { + $this->assertArrayNotHasKey('smf_tests_in_memory_only', Config::$modSettings); + } + + public function testAdminIdFindsAnAdministrator(): void + { + $id = $this->adminId(); + + $this->assertGreaterThan(0, $id); + + $this->actingAs($id); + + $this->assertSame($id, \SMF\User::$me->id); + $this->assertTrue(\SMF\User::$me->is_admin, 'actingAs() did not produce an administrator'); + } + + public function testNothingIsLoggedByAnEmptyTest(): void + { + $this->assertNoErrorsLogged(); + } + + /** + * The assertion is only worth anything if it can fail, and it reads the log + * through a watermark taken in setUp() rather than a count, so an empty log + * is not what makes it pass. + */ + public function testAssertNoErrorsLoggedNoticesALoggedError(): void + { + Db::$db->insert( + 'insert', + '{db_prefix}log_errors', + [ + 'log_time' => 'int', + 'id_member' => 'int', + 'ip' => 'inet', + 'url' => 'string', + 'message' => 'string', + 'session' => 'string', + 'error_type' => 'string', + 'file' => 'string', + 'line' => 'int', + 'backtrace' => 'string', + ], + [[time(), 0, '', '', 'canary', '', 'general', __FILE__, __LINE__, '[]']], + ['id_error'], + ); + + $this->expectException(AssertionFailedError::class); + + $this->assertNoErrorsLogged(); + } +} diff --git a/tests/Integration/Installation.php b/tests/Integration/Installation.php new file mode 100644 index 00000000000..4505b2675f8 --- /dev/null +++ b/tests/Integration/Installation.php @@ -0,0 +1,111 @@ +getMessage(); + } + + if (empty(Config::$db_type) || empty(Config::$db_name)) { + return 'Settings.php names no database'; + } + + // index.php builds this before anything can ask for a service. + Container::init(); + + try { + // non_fatal, or a refused connection ends the process with SMF's own + // database error page instead of letting us report it here. + Db::load(['non_fatal' => true]); + } catch (\Throwable $e) { + return 'could not connect to ' . Config::$db_type . ': ' . $e->getMessage(); + } + + if (!isset(Db::$db->connection)) { + return 'could not connect to ' . Config::$db_type . ' as ' . Config::$db_user; + } + + // Connecting is not the same as finding a forum: the dev environment + // writes a Settings.php long before anything is installed behind it. + try { + Config::reloadModSettings(); + } catch (\Throwable $e) { + return 'the database holds no forum: ' . $e->getMessage(); + } + + if (empty(Config::$modSettings['smfVersion'])) { + return 'the database holds no forum (no smfVersion in ' . Config::$db_prefix . 'settings)'; + } + + return ''; + } +} diff --git a/tests/Integration/IntegrationTestCase.php b/tests/Integration/IntegrationTestCase.php new file mode 100644 index 00000000000..7ba116e57df --- /dev/null +++ b/tests/Integration/IntegrationTestCase.php @@ -0,0 +1,270 @@ +transaction('begin'); + + // $modSettings is a plain static array, so a test that calls + // updateModSettings() changes it for everything that runs after it. The + // rollback puts the table back, not the copy in memory. + $this->mod_settings_backup = Config::$modSettings; + + $this->error_watermark = $this->lastErrorId(); + } + + protected function tearDown(): void + { + Db::$db->transaction('rollback'); + + Config::$modSettings = $this->mod_settings_backup; + + // Hooks added with permanent: false live in $modSettings, so restoring it + // above has already removed them. This only puts the switch back. + IntegrationHook::$enabled = true; + + parent::tearDown(); + } + + /** + * Becomes the given member for the rest of the test. + * + * This is the seam Login2::DoLogin() itself uses once it has checked the + * password, so everything downstream - permissions, bans, logging - behaves + * as it would for a real login, with no cookie and no request involved. + * + * Note that User::$me is a typed static and cannot be unset once assigned, + * so this outlives the test. Say who you are rather than assuming. + * + * @param int $id The member to become. + */ + protected function actingAs(int $id): void + { + User::setMe($id); + } + + /** + * The id of an administrator, for actingAs(). + * + * @return int The lowest member id in group 1. + */ + protected function adminId(): int + { + $request = Db::$db->query( + 'SELECT id_member + FROM {db_prefix}members + WHERE id_group = {int:admin_group} + ORDER BY id_member + LIMIT 1', + [ + 'admin_group' => 1, + ], + ); + + $row = Db::$db->fetch_assoc($request); + Db::$db->free_result($request); + + $this->assertNotEmpty($row, 'the forum has no administrator'); + + return (int) $row['id_member']; + } + + /** + * Registers a hook for the duration of the test. + * + * permanent: false keeps it in Config::$modSettings and out of the database, + * so tearDown() removes it by restoring that array. + * + * @param string $name The hook to add to, e.g. 'integrate_verify_user'. + * @param string $function The callable, in any form Utils::getCallable() takes. + */ + protected function hook(string $name, string $function): void + { + IntegrationHook::add($name, $function, false); + } + + /** + * Asserts the forum logged nothing since the test started. + * + * Most of what goes wrong in SMF is recorded here rather than shown, so a + * page that returned the right thing while quietly logging an undefined index + * has still regressed. + * + * @param string $message Optional context for the failure. + */ + protected function assertNoErrorsLogged(string $message = ''): void + { + $request = Db::$db->query( + 'SELECT error_type, message, file, line + FROM {db_prefix}log_errors + WHERE id_error > {int:watermark} + ORDER BY id_error', + [ + 'watermark' => $this->error_watermark, + ], + ); + + $this->assertNotFalse( + $request, + rtrim($message . "\n") . 'could not read the error log, so this proves nothing', + ); + + $errors = []; + + while ($row = Db::$db->fetch_assoc($request)) { + $errors[] = sprintf( + ' [%s] %s (%s:%d)', + $row['error_type'], + html_entity_decode((string) $row['message'], ENT_QUOTES | ENT_HTML5, 'UTF-8'), + $row['file'], + $row['line'], + ); + } + + Db::$db->free_result($request); + + $this->assertSame( + [], + $errors, + rtrim($message . "\n") . "the forum logged " . \count($errors) . " error(s):\n" . implode("\n", $errors), + ); + } + + /** + * Runs a query and returns its first row. + * + * Exists because a failed query is not an exception here. MySQL returns + * false and carries on; PostgreSQL returns false and additionally puts the + * surrounding transaction into a failed state, so every later query in the + * same test returns false too. Handing that false to fetch_assoc() produces + * a TypeError about argument #1, which says nothing about what went wrong. + * + * @param string $sql The query, in SMF's dialect. + * @param array $params Its parameters. + * @return array|null The first row, or null when there were none. + */ + protected function queryRow(string $sql, array $params = []): ?array + { + $request = Db::$db->query($sql, $params); + + $this->assertNotFalse( + $request, + "the query failed:\n" . trim($sql) + . "\non PostgreSQL this also aborts the transaction, so every query after it fails too", + ); + + $row = Db::$db->fetch_assoc($request); + Db::$db->free_result($request); + + return \is_array($row) ? $row : null; + } + + /** + * Reads a setting straight out of the table. + * + * Bypasses Config::$modSettings and its cache, so what comes back is what + * the database actually holds rather than what the process believes. + * + * @param string $variable The setting to read. + * @return string|null The value, or null when there is no such row. + */ + protected function rawSetting(string $variable): ?string + { + $row = $this->queryRow( + 'SELECT value + FROM {db_prefix}settings + WHERE variable = {string:variable}', + [ + 'variable' => $variable, + ], + ); + + return $row === null ? null : (string) $row['value']; + } + + /** + * The highest id_error currently in the log. + * + * @return int The id, or 0 when nothing has ever been logged. + */ + private function lastErrorId(): int + { + $request = Db::$db->query( + 'SELECT COALESCE(MAX(id_error), 0) AS id_error + FROM {db_prefix}log_errors', + [], + ); + + if ($request === false) { + return 0; + } + + $row = Db::$db->fetch_assoc($request); + Db::$db->free_result($request); + + return (int) ($row['id_error'] ?? 0); + } +} diff --git a/tests/Integration/ModSettingsTest.php b/tests/Integration/ModSettingsTest.php new file mode 100644 index 00000000000..b609eaff399 --- /dev/null +++ b/tests/Integration/ModSettingsTest.php @@ -0,0 +1,116 @@ + '41']); + + $this->assertSame('41', $this->rawSetting(self::COUNTER)); + $this->assertSame('41', Config::$modSettings[self::COUNTER]); + } + + public function testIncrementsACounter(): void + { + Config::updateModSettings([self::COUNTER => '41']); + Config::updateModSettings([self::COUNTER => true], true); + + $this->assertSame( + 42, + (int) $this->rawSetting(self::COUNTER), + 'the counter did not increment - on PostgreSQL this means the ' + . 'arithmetic was rejected and the failure swallowed', + ); + } + + public function testDecrementsACounter(): void + { + Config::updateModSettings([self::COUNTER => '41']); + Config::updateModSettings([self::COUNTER => false], true); + + $this->assertSame(40, (int) $this->rawSetting(self::COUNTER)); + } + + /** + * The value has to stay something the next increment can read back, so a + * cast that leaves '42.0000' behind is not good enough. + */ + public function testAnIncrementedCounterStaysAPlainInteger(): void + { + Config::updateModSettings([self::COUNTER => '41']); + Config::updateModSettings([self::COUNTER => true], true); + + $this->assertMatchesRegularExpression( + '~^\d+$~', + (string) $this->rawSetting(self::COUNTER), + 'the incremented value is not a plain integer, so it will not survive a round trip', + ); + } + + public function testCountersCanBeIncrementedRepeatedly(): void + { + // Starts at 10 rather than 0 on purpose: see the test below for why a + // brand new setting cannot be created holding a falsy value. + Config::updateModSettings([self::COUNTER => '10']); + + for ($i = 0; $i < 3; $i++) { + Config::updateModSettings([self::COUNTER => true], true); + } + + $this->assertSame(13, (int) $this->rawSetting(self::COUNTER)); + } + + /** + * A setting that does not exist yet and would only be set to nothingness is + * skipped rather than written. That is deliberate, and it is a sharp edge: + * seeding a counter at zero looks like it worked and leaves no row, so the + * first increment then has nothing to increment. + */ + public function testDoesNotCreateANewSettingHoldingAFalsyValue(): void + { + Config::updateModSettings([self::COUNTER => '0']); + + $this->assertNull($this->rawSetting(self::COUNTER)); + + // An existing one can be set to zero perfectly well. + Config::updateModSettings([self::COUNTER => '7']); + Config::updateModSettings([self::COUNTER => '0']); + + $this->assertSame('0', $this->rawSetting(self::COUNTER)); + } + + public function testUpdatingSettingsLogsNoErrors(): void + { + Config::updateModSettings([self::COUNTER => '1']); + Config::updateModSettings([self::COUNTER => true], true); + Config::updateModSettings([self::COUNTER => false], true); + + $this->assertNoErrorsLogged('updating a counter should be silent'); + } +} diff --git a/tests/Integration/SchemaTest.php b/tests/Integration/SchemaTest.php new file mode 100644 index 00000000000..ba8855bb4c2 --- /dev/null +++ b/tests/Integration/SchemaTest.php @@ -0,0 +1,100 @@ +assertNotEmpty(Table::getAll('v3_0'), 'the v3_0 schema declares no tables at all'); + } + + public function testEveryDeclaredTableExists(): void + { + $existing = array_map( + static fn($table): string => strtolower($table), + Db::$db->list_tables(), + ); + + $missing = []; + + foreach (Table::getAll('v3_0') as $table) { + if (!\in_array(strtolower(Db::$db->prefix . $table->name), $existing, true)) { + $missing[] = $table->name; + } + } + + $this->assertSame([], $missing, 'tables the schema declares but the database does not have'); + } + + public function testEveryDeclaredColumnExists(): void + { + $missing = []; + + foreach (Table::getAll('v3_0') as $table) { + $columns = array_map( + static fn($column): string => strtolower($column), + Db::$db->list_columns('{db_prefix}' . $table->name), + ); + + // A table that is missing entirely is the other test's business. + if ($columns === []) { + continue; + } + + foreach ($table->columns as $column) { + if (!\in_array(strtolower($column->name), $columns, true)) { + $missing[] = $table->name . '.' . $column->name; + } + } + } + + $this->assertSame([], $missing, 'columns the schema declares but the database does not have'); + } + + /** + * The reverse direction, which is the one that catches a migration that + * dropped a column in the schema but not in the database, or a table left + * behind by an older version. + */ + public function testTheDatabaseHasNoColumnsTheSchemaDoesNotDeclare(): void + { + $unexpected = []; + + foreach (Table::getAll('v3_0') as $table) { + $declared = array_map( + static fn($column): string => strtolower($column->name), + $table->columns, + ); + + foreach (Db::$db->list_columns('{db_prefix}' . $table->name) as $column) { + if (!\in_array(strtolower($column), $declared, true)) { + $unexpected[] = $table->name . '.' . $column; + } + } + } + + $this->assertSame([], $unexpected, 'columns the database has that the schema does not declare'); + } +} diff --git a/tests/Integration/index.php b/tests/Integration/index.php new file mode 100644 index 00000000000..2844a3b9e7b --- /dev/null +++ b/tests/Integration/index.php @@ -0,0 +1,8 @@ +setPsr4('SMF\\', TESTS_BOARDDIR . '/Sources'); $loader->setPsr4('SMF\\Themes\\', TESTS_BOARDDIR . '/Themes'); +// The unit tests are each self-contained, so nothing had to autoload them. +// Anything sharing a base class or a helper does, and registering it here keeps +// it beside the other two rather than adding an autoload-dev section that only +// the test suite would ever use. +$loader->setPsr4('SMF\\Tests\\', TESTS_BOARDDIR . '/tests'); + /* * Paths and the default language, which the Unicode and entity helpers need in * order to locate their data files. These are the only pieces of Config the suite From 241250da20b5f247d2965a6d8d5d3545718bd86a Mon Sep 17 00:00:00 2001 From: albertlast Date: Sun, 2 Aug 2026 08:28:13 +0200 Subject: [PATCH 20/26] Lets the section comment fixer place its own banners The banners in the new test classes were written by hand with the wrong number of asterisks, so SMF/section_comments did not recognise them and inserted its own alongside, leaving IntegrationTestCase with two "Internal properties" headings and two "Internal methods" ones. AGENTS.md says not to hand-write these. Removes them and takes what the fixer produces, along with the single_quote, native_function_invocation and no_unused_imports changes it wanted in the same pass. Co-Authored-By: Claude Opus 5 Signed-off-by: albertlast --- tests/Integration/HarnessTest.php | 8 ++++++++ tests/Integration/IntegrationTestCase.php | 18 +++++++++--------- tests/Integration/ModSettingsTest.php | 9 ++++++++- tests/Integration/SchemaTest.php | 4 ++++ 4 files changed, 29 insertions(+), 10 deletions(-) diff --git a/tests/Integration/HarnessTest.php b/tests/Integration/HarnessTest.php index 894aaae79c5..ac7ceb8ffcd 100644 --- a/tests/Integration/HarnessTest.php +++ b/tests/Integration/HarnessTest.php @@ -20,12 +20,20 @@ #[CoversNothing] class HarnessTest extends IntegrationTestCase { + /***************** + * Class constants + *****************/ + /** * The variable the rollback tests write. Named so that finding it left * behind in a real forum points straight back here. */ private const LEFTOVER = 'smf_tests_rollback_canary'; + /**************** + * Public methods + ****************/ + public function testTheForumIsInstalled(): void { $this->assertNotEmpty(Config::$modSettings['smfVersion']); diff --git a/tests/Integration/IntegrationTestCase.php b/tests/Integration/IntegrationTestCase.php index 7ba116e57df..3475c80dd33 100644 --- a/tests/Integration/IntegrationTestCase.php +++ b/tests/Integration/IntegrationTestCase.php @@ -28,9 +28,9 @@ */ abstract class IntegrationTestCase extends TestCase { - /******************* + /********************* * Internal properties - *******************/ + *********************/ /** * @var array Copy of Config::$modSettings taken before the test ran. @@ -42,9 +42,9 @@ abstract class IntegrationTestCase extends TestCase */ private int $error_watermark = 0; - /**************** - * Public methods - ****************/ + /*********************** + * Public static methods + ***********************/ public static function setUpBeforeClass(): void { @@ -58,9 +58,9 @@ public static function setUpBeforeClass(): void } } - /******************* + /****************** * Internal methods - *******************/ + ******************/ protected function setUp(): void { @@ -175,7 +175,7 @@ protected function assertNoErrorsLogged(string $message = ''): void $errors = []; while ($row = Db::$db->fetch_assoc($request)) { - $errors[] = sprintf( + $errors[] = \sprintf( ' [%s] %s (%s:%d)', $row['error_type'], html_entity_decode((string) $row['message'], ENT_QUOTES | ENT_HTML5, 'UTF-8'), @@ -189,7 +189,7 @@ protected function assertNoErrorsLogged(string $message = ''): void $this->assertSame( [], $errors, - rtrim($message . "\n") . "the forum logged " . \count($errors) . " error(s):\n" . implode("\n", $errors), + rtrim($message . "\n") . 'the forum logged ' . \count($errors) . " error(s):\n" . implode("\n", $errors), ); } diff --git a/tests/Integration/ModSettingsTest.php b/tests/Integration/ModSettingsTest.php index b609eaff399..93a8312a962 100644 --- a/tests/Integration/ModSettingsTest.php +++ b/tests/Integration/ModSettingsTest.php @@ -6,7 +6,6 @@ use PHPUnit\Framework\Attributes\CoversMethod; use SMF\Config; -use SMF\Db\DatabaseApi as Db; /** * Config::updateModSettings() against a real database, on whichever engine is @@ -26,8 +25,16 @@ #[CoversMethod(Config::class, 'updateModSettings')] class ModSettingsTest extends IntegrationTestCase { + /***************** + * Class constants + *****************/ + private const COUNTER = 'smf_tests_counter'; + /**************** + * Public methods + ****************/ + public function testWritesAValue(): void { Config::updateModSettings([self::COUNTER => '41']); diff --git a/tests/Integration/SchemaTest.php b/tests/Integration/SchemaTest.php index ba8855bb4c2..145975992b9 100644 --- a/tests/Integration/SchemaTest.php +++ b/tests/Integration/SchemaTest.php @@ -23,6 +23,10 @@ #[CoversNothing] class SchemaTest extends IntegrationTestCase { + /**************** + * Public methods + ****************/ + public function testTheSchemaDeclaresTables(): void { // Guards the two tests below: if getAll() ever returns nothing they From c7b53cdd429a94e7cce69d871e88a4fa40354eed Mon Sep 17 00:00:00 2001 From: albertlast Date: Sun, 2 Aug 2026 08:28:14 +0200 Subject: [PATCH 21/26] Removes the trailing tabs from a blank line in PM search c344b5c23 left a line holding nothing but three tabs, which no_whitespace_in_blank_line rejects. It has not turned CI red so far because the style workflow normally only looks at the files a pull request changed. It checks everything when composer.lock is part of the diff, which is how this surfaced, and it will do the same to any other branch that touches a dependency. Co-Authored-By: Claude Opus 5 Signed-off-by: albertlast --- Sources/PersonalMessage/Search.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/PersonalMessage/Search.php b/Sources/PersonalMessage/Search.php index 76cc75fdbaa..a6475866e55 100644 --- a/Sources/PersonalMessage/Search.php +++ b/Sources/PersonalMessage/Search.php @@ -505,7 +505,7 @@ protected function setUserQuery(): void $searchq_parameters['name_' . $k] = $v; $clauses[] = '{raw:real_name} LIKE {string:name_' . $k . '}'; } - + if (Db::$db->num_rows($request) == 0) { $this->user_query = 'AND pm.id_member_from = 0 AND (' . implode(' OR ', $clauses) . ')'; } else { From 61a35f536bf78bd2c51278ef349f0bfb25e88d0b Mon Sep 17 00:00:00 2001 From: albertlast Date: Sun, 2 Aug 2026 20:59:30 +0200 Subject: [PATCH 22/26] Removes install.php once the forum is installed The installer tells you to delete it and cannot do it itself: the ?delete link it offers is a GET, and command line arguments only ever reach $_POST, so nothing on the CLI path ever gets there. Leaving it behind is not cosmetic. Settings.php redirects every request back into the installer while the file exists, so the forum the script just built is unreachable, and SMF puts a "MAJOR SECURITY RISK: you have not removed install.php" box on every page it shows an administrator - which also lands in front of anything else a test or a person is trying to read on that page. Deleting it is safe for a reinstall because install_one() calls reset.sh first, and reset.sh clears Settings.php and then blocks until the entrypoint has staged a fresh copy. Adds a check in front of the two installer passes to say so out loud when it has not: without one, php reports "Could not open input file: install.php", which reads like a broken script rather than a stack that was never made installable. Signed-off-by: albertlast --- .docker/README.md | 8 ++++++++ .docker/install-forum.sh | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/.docker/README.md b/.docker/README.md index 111bef70986..57afbed0550 100644 --- a/.docker/README.md +++ b/.docker/README.md @@ -71,6 +71,14 @@ though it succeeded — it pauses so a human can read its "N duplicate tables ignored" report, and the form's `pop_done` field is the short-circuit past it. Passing `pop_done` on the first pass would skip building the schema entirely. +It then deletes `install.php`, which the installer asks for but cannot do +itself — its `?delete` link is a GET, and command line arguments only ever reach +`$_POST`. That matters more than it sounds: while the file is there +`Settings.php` redirects every request back into the installer, and SMF puts a +"MAJOR SECURITY RISK" box on every page it shows an administrator. Reinstalling +still works, because `reset.sh` runs first and does not return until the +entrypoint has staged a fresh copy. + Two flags worth knowing: - `--force` reinstalls even when a forum is already there. Without it the diff --git a/.docker/install-forum.sh b/.docker/install-forum.sh index 613b604c90a..8bc55700270 100755 --- a/.docker/install-forum.sh +++ b/.docker/install-forum.sh @@ -96,6 +96,13 @@ install_one() { --password2="$SMF_ADMIN_PASS" ) + # reset.sh does not return until the entrypoint has staged this, so its + # absence means something went wrong there rather than here. Worth saying so: + # without it php reports "Could not open input file: install.php", which reads + # like a broken script rather than a forum that was never made installable. + docker compose exec -T web test -f install.php \ + || die "${smf_type}: install.php is not staged, so there is nothing to run (docker compose logs web)" + log "${smf_type}: building the schema" docker compose exec -T web php install.php "${args[@]}" >/dev/null @@ -107,6 +114,17 @@ install_one() { [ -n "$version" ] || die "${smf_type}: the installer finished but the forum is not installed" + # The installer tells you to delete this and cannot do it itself: its ?delete + # link is a GET, and command line arguments only ever reach $_POST. Leaving it + # is not cosmetic - Settings.php redirects every request back into the + # installer while it is there, and SMF puts a "MAJOR SECURITY RISK: you have + # not removed install.php" box on every page it shows an administrator. + # + # Safe to delete even though a reinstall needs it again: install_one() always + # calls reset.sh first, and reset.sh clears Settings.php and waits for the + # entrypoint to put a fresh copy back before returning. + rm -f install.php + log "${smf_type}: installed SMF ${version}" if [ "$PIN_SECRETS" -eq 1 ]; then From c67166ec38b577ff82da5a2e4d5129671137e6ec Mon Sep 17 00:00:00 2001 From: albertlast Date: Sun, 2 Aug 2026 16:21:24 +0200 Subject: [PATCH 23/26] Adds HTTP smoke tests that drive a running forum The integration suite reaches the database, but not a page. Everything between a request arriving and HTML coming back - the session, the cookies, the theme, the templates, the permission checks - had no automated coverage at all, and that is where the failures people actually report live. Requests have to be real ones: obExit(), redirectexit() and fatal*() all end in exit, and Db::$db, ActionTrait::$obj and Theme::$loaded cannot be reset, so a test process can carry out one request in itself and no more. tests/Support/HttpClient.php is a small browser built on the curl extension the forum already requires, so this costs no new dependency. Three files to start: a sweep of the pages a guest can reach, the login journey, and starting a topic and replying to it. Every one of them ends in assertNoErrorsLogged(), which is the point - SMF records most of what goes wrong in log_errors rather than showing it, so a page can return a flawless 200 while logging an undefined index on every hit. Four things about SMF made these harder to write than expected, and each is commented where it bites rather than worked around silently: - The first request of a new session regenerates it, so a security token minted on the very first page a visitor sees can never be validated. It looks like a broken token, not a replaced session. - Only the button that was clicked gets submitted. The posting form offers "preview" and "post"; sending both means preview wins and the post is never made, with an ordinary 200 to show for it. - Security::spamProtection() allows one login or post every two seconds per IP, and tests are much faster than people, so submitForm() waits it out once instead of failing at random. - curl only writes cookies with an expiry to its jar file, so a handle opened per request loses the session every time. HTTP tests cannot be wrapped in a transaction - the request runs in the web server's process on its own connection, and on MySQL's REPEATABLE READ an open transaction here would never see what it wrote, quietly making assertNoErrorsLogged() incapable of failing. IntegrationTestCase gains usesTransaction() so they can opt out, and PostingTest removes what it creates through Topic::remove(). Co-Authored-By: Claude Opus 5 Signed-off-by: albertlast --- .docker/README.md | 12 + .docker/test.sh | 8 +- AGENTS.md | 23 ++ tests/Integration/Http/GuestPagesTest.php | 168 ++++++++++ tests/Integration/Http/HttpTestCase.php | 274 ++++++++++++++++ tests/Integration/Http/LoginTest.php | 99 ++++++ tests/Integration/Http/PostingTest.php | 222 +++++++++++++ tests/Integration/Http/index.php | 8 + tests/Integration/IntegrationTestCase.php | 24 +- tests/Support/HttpClient.php | 361 ++++++++++++++++++++++ tests/Support/HttpResponse.php | 259 ++++++++++++++++ tests/Support/index.php | 8 + 12 files changed, 1463 insertions(+), 3 deletions(-) create mode 100644 tests/Integration/Http/GuestPagesTest.php create mode 100644 tests/Integration/Http/HttpTestCase.php create mode 100644 tests/Integration/Http/LoginTest.php create mode 100644 tests/Integration/Http/PostingTest.php create mode 100644 tests/Integration/Http/index.php create mode 100644 tests/Support/HttpClient.php create mode 100644 tests/Support/HttpResponse.php create mode 100644 tests/Support/index.php diff --git a/.docker/README.md b/.docker/README.md index 02dabab8740..3b5126423f9 100644 --- a/.docker/README.md +++ b/.docker/README.md @@ -131,6 +131,18 @@ considerably less than it looks like it does. The unit suite needs none of this — `composer test` runs everything, and the integration tests skip themselves when there is no forum to talk to. +Some of the tests sign in, so they need to know the administrator. They default +to what `install-forum.sh` creates (`admin` / `password`); if your forum has +different credentials, export them: + +```sh +SMF_ADMIN_USER=admin SMF_ADMIN_PASS='…' .docker/test.sh +``` + +Getting that wrong makes those tests **skip**, with a message saying so, rather +than fail — a password the suite does not know is a misconfigured forum, not a +regression. + ### Installing in a browser instead On first boot the entrypoint writes a `Settings.php` pre-filled for the chosen diff --git a/.docker/test.sh b/.docker/test.sh index 7ea104629e9..8d6d818e206 100755 --- a/.docker/test.sh +++ b/.docker/test.sh @@ -66,7 +66,13 @@ for smf_type in $ENGINES; do log "${smf_type}: running the tests" - if docker compose exec -T web vendor/bin/phpunit --no-coverage --colors=always "${PHPUNIT_ARGS[@]+"${PHPUNIT_ARGS[@]}"}"; then + # The HTTP tests sign in, so they need to be told who the administrator is. + # These default to what install-forum.sh created; export them to point the + # suite at a forum that was set up some other way. + if docker compose exec -T \ + -e SMF_ADMIN_USER="$SMF_ADMIN_USER" \ + -e SMF_ADMIN_PASS="$SMF_ADMIN_PASS" \ + web vendor/bin/phpunit --no-coverage --colors=always "${PHPUNIT_ARGS[@]+"${PHPUNIT_ARGS[@]}"}"; then log "${smf_type}: passed" else warn "${smf_type}: FAILED" diff --git a/AGENTS.md b/AGENTS.md index 1cf301db383..09fbf5d8363 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -166,6 +166,29 @@ Two things the rollback does not cover: **DDL**, since MySQL commits implicitly `CREATE`/`ALTER`/`DROP`; and anything happening in another process, such as a request made over HTTP, which runs on its own connection. +#### HTTP tests + +`tests/Integration/Http/` drives the forum over the wire, through `HttpTestCase`. Use it +when the thing worth proving is that a *page* works: the session, the cookies, the theme +and the templates are all in the path, and none of them are otherwise reachable. + +They cannot use a transaction and do not try to - see `HttpTestCase::usesTransaction()` - +so a test that writes cleans up after itself. Four things about SMF make writing them +harder than it looks, all of them handled in the base class: + +- **Arrive at the forum before submitting anything.** The first request of a new session + regenerates it, so a security token minted on the very first page a visitor sees can + never be validated. The symptom is a 403 "Token verification failed" that looks like a + broken token rather than a replaced session. +- **Send the button you mean to press.** `HttpResponse::formFields()` deliberately leaves + buttons out. The posting form has both `preview` and `post`; submitting the pair means + preview wins, the post is never made, and the response is a perfectly ordinary 200. +- **Flood control will hit you.** `Security::spamProtection()` allows a moderator one + login or post every two seconds per IP, and tests are far faster than people. + `submitForm()` waits it out once rather than failing at random. +- **Quote `errorText()` in failure messages, not the body.** A fatal error in SMF is a + normal page, and its first few hundred characters are the menu. + **Run both engines.** This is not thoroughness for its own sake — the two disagree often enough to matter. `ModSettingsTest` pins a bug that *passes on MySQL with the bug still in place*, because MySQL silently coerces text to a number where PostgreSQL refuses. diff --git a/tests/Integration/Http/GuestPagesTest.php b/tests/Integration/Http/GuestPagesTest.php new file mode 100644 index 00000000000..7c321f9fba3 --- /dev/null +++ b/tests/Integration/Http/GuestPagesTest.php @@ -0,0 +1,168 @@ +fetch($path); + + $this->assertLooksLikeAForumPage($response, $name); + $this->assertNoErrorsLogged($name . ' (' . $path . ') logged something.' . "\n"); + } + + public function testTheBoardIndexListsAtLeastOneBoard(): void + { + $response = $this->fetch(''); + + $this->assertGreaterThan( + 0, + $response->xpath('//a[contains(@href, "board=")]')->length, + 'the board index links to no boards, so a fresh install has nothing in it', + ); + + $this->assertNoErrorsLogged(); + } + + /** + * The one page here that is not HTML. It is worth its place because the feed + * is built by hand rather than by the template layer, so nothing else in this + * file would notice it breaking. + */ + public function testTheFeedIsXmlAndParses(): void + { + $response = $this->fetch('?action=.xml;type=rss2'); + + $this->assertStringContainsString( + 'xml', + strtolower($response->headers['content-type'] ?? ''), + 'the feed did not come back as XML', + ); + + $previous = libxml_use_internal_errors(true); + $parsed = simplexml_load_string($response->body); + libxml_clear_errors(); + libxml_use_internal_errors($previous); + + $this->assertNotFalse($parsed, 'the feed is not well formed XML'); + $this->assertNoErrorsLogged('the feed logged something.' . "\n"); + } + + /** + * An action that does not exist should be a 404, not a 200 with an apology + * and not a 500. + */ + public function testAnUnknownActionIsNotFound(): void + { + $this->fetch('?action=smf_tests_no_such_action', 404); + } + + /** + * Registration refuses to start for a visitor who sends no cookies at all, + * because a registration that cannot keep a session cannot be completed. + * + * It is in its own test rather than the sweep above because it is the one + * page here that a cold request is genuinely not allowed to reach, and the + * difference between the two halves is worth stating: arriving at the forum + * first is what makes it work, and that is what a browser does. + */ + public function testRegistrationNeedsASessionFirst(): void + { + $this->http->forgetCookies(); + + $cold = $this->http->get('?action=signup'); + + $this->assertSame(403, $cold->status, 'a cookieless visitor was allowed into registration'); + + // Arrive at the forum the way a person would, which sets the session + // cookie, and then go to register. + $this->fetch(''); + + $agreement = $this->fetch('?action=signup'); + + $this->assertLooksLikeAForumPage($agreement, 'the registration agreement'); + + // requireAgreement is on by default, so step one is the agreement rather + // than the form. Note the form has to be named: the first form on any SMF + // page is the search box in the header. + $registration_form = '//form[contains(@action, "action=signup")]'; + + $this->assertGreaterThan( + 0, + $agreement->xpath($registration_form . '//input[@name="accept_agreement"]')->length, + 'registration did not start at the agreement', + ); + + // Buttons are not submitted unless named, so say which one we press. + $form = $this->http->submit($agreement, [ + 'accept_agreement' => 'I accept the terms of the agreement.', + ], $registration_form); + + $this->assertSame(200, $form->status, 'accepting the agreement returned ' . $form->status); + + $this->assertGreaterThan( + 0, + $form->xpath('//input[@name="user"]')->length, + 'accepting the agreement did not lead to the registration form', + ); + + $this->assertNoErrorsLogged('registering logged something.' . "\n"); + } + + /*********************** + * Public static methods + ***********************/ + + /** + * The pages a guest can reach on a stock install. + * + * Deliberately only actions that a fresh forum can serve without any content + * having been created and without being logged in, so this stays green on a + * forum straight out of .docker/install-forum.sh. + * + * @return array The cases, path and a readable name. + */ + public static function guestPages(): array + { + return [ + 'board index' => ['', 'the board index'], + 'help' => ['?action=help', 'help'], + 'login form' => ['?action=login', 'the login form'], + 'recent posts' => ['?action=recent', 'recent posts'], + 'unread' => ['?action=unread', 'unread posts'], + 'search form' => ['?action=search', 'the search form'], + 'member list' => ['?action=mlist', 'the member list'], + 'statistics' => ['?action=stats', 'the statistics page'], + 'credits' => ['?action=credits', 'the credits page'], + 'who is online' => ['?action=who', 'who is online'], + 'agreement' => ['?action=agreement', 'the registration agreement'], + 'first board' => ['?board=1.0', 'the first board'], + ]; + } +} diff --git a/tests/Integration/Http/HttpTestCase.php b/tests/Integration/Http/HttpTestCase.php new file mode 100644 index 00000000000..a85e423c6c4 --- /dev/null +++ b/tests/Integration/Http/HttpTestCase.php @@ -0,0 +1,274 @@ +http = new HttpClient(); + + // Arrive at the forum before doing anything else, which is what a person + // does and what the tests below depend on. + // + // The very first request of a new session regenerates it - SMF sets a + // guest login cookie, and Cookie::setLoginCookie() throws the session + // away and starts another whenever that value changes. Anything minted + // earlier in that same request is minted against the session that just + // went away, so a security token taken from the first page a visitor + // ever sees can never be validated. Posting that form comes back 403, + // "Token verification failed", with nothing to suggest the token was + // fine and the session underneath it was not. + $this->http->get(''); + } + + /** + * Signs in as the forum administrator. + * + * The credentials are the ones .docker/install-forum.sh uses, overridable + * through the environment for a forum that was set up some other way. + * + * @return HttpResponse The response to the login post. + */ + protected function signInAsAdmin(): HttpResponse + { + $response = $this->attemptSignIn(); + + if (self::isThrottled($response)) { + sleep(self::FLOOD_WAIT); + + $response = $this->attemptSignIn(); + } + + // A password that does not match is a misconfigured forum rather than a + // regression, and failing every test in the file over it would say + // nothing useful. Skipping names the variable to set. + if (str_contains($response->text(), 'username or password you entered is incorrect')) { + self::markTestSkipped( + 'cannot sign in as "' . self::adminName() . '". Set SMF_ADMIN_USER and ' + . 'SMF_ADMIN_PASS to this forum\'s administrator, or reinstall with ' + . '.docker/install-forum.sh --engine mysql --force', + ); + } + + $this->assertLessThan( + 400, + $response->status, + 'logging in returned ' . $response->status . ': ' . $response->errorText(), + ); + + return $response; + } + + /** + * Submits a form, waiting out flood control if it gets in the way. + * + * Tests do in half a second what a person would take a minute over, so they + * trip SMF's flood protection routinely. That is the forum working, not a + * regression, and the difference between a suite people trust and one that + * fails now and then for reasons nobody can reproduce. + * + * @param HttpResponse $page The page holding the form. + * @param array $overrides Values to change or add, including the button. + * @param string $xpath Which form. + * @return HttpResponse The response. + */ + protected function submitForm(HttpResponse $page, array $overrides, string $xpath): HttpResponse + { + $response = $this->http->submit($page, $overrides, $xpath); + + if (!self::isThrottled($response)) { + return $response; + } + + sleep(self::FLOOD_WAIT); + + // The page has to be fetched again rather than resubmitted: its security + // token was spent on the attempt that just bounced. + return $this->http->submit($this->http->get($page->url), $overrides, $xpath); + } + + /** + * Asserts the client is, or is not, signed in. + * + * Uses the logout link, which the theme only renders for a member. + * + * @param bool $expected Whether we should be signed in. + * @param string $message What was being checked. + */ + protected function assertSignedIn(bool $expected, string $message = ''): void + { + $signed_in = $this->fetch('')->xpath('//a[contains(@href, "action=logout")]')->length > 0; + + $this->assertSame($expected, $signed_in, $message !== '' ? $message : ($expected ? 'not signed in' : 'still signed in')); + } + + /** + * Fetches a page and asserts it came back whole. + * + * @param string $path Where to go, as HttpClient::get() takes it. + * @param int $expected The status it should return. + * @return HttpResponse The response, for further assertions. + */ + protected function fetch(string $path, int $expected = 200): HttpResponse + { + $response = $this->http->get($path); + + $this->assertSame( + $expected, + $response->status, + $path . ' returned ' . $response->status . ' from ' . $this->http->base_url, + ); + + return $response; + } + + /** + * Asserts a page is a real forum page rather than an error SMF rendered + * with a 200. + * + * A fatal error in SMF is a normal page with an apologetic message in it, so + * the status code alone proves very little. + * + * @param HttpResponse $response The response to check. + * @param string $where What was being fetched, for the failure message. + */ + protected function assertLooksLikeAForumPage(HttpResponse $response, string $where): void + { + $this->assertNotSame('', $response->title(), $where . ' has no '); + + $this->assertGreaterThan( + 0, + $response->xpath('//div[@id="footer"] | //footer | //*[@id="bot"]')->length, + $where . ' has no footer, so the template did not finish rendering', + ); + + $this->assertSame( + 0, + $response->xpath('//*[contains(@class, "errorbox")]')->length, + $where . ' rendered an error box: ' . $response->errorText(), + ); + } + + /** + * One go at the login form. + * + * @return HttpResponse The response to the post. + */ + private function attemptSignIn(): HttpResponse + { + $form = $this->fetch('?action=login'); + + return $this->http->submit($form, [ + 'user' => self::adminName(), + 'passwrd' => self::adminPassword(), + ], '//form[contains(@action, "action=login2")]'); + } + + /************************* + * Internal static methods + *************************/ + + /** + * Whether a response is SMF turning us away for going too fast. + * + * @param HttpResponse $response The response to look at. + * @return bool Whether flood control rejected it. + */ + protected static function isThrottled(HttpResponse $response): bool + { + $error = $response->errorText(); + + return str_contains($error, 'You will have to wait') + || str_contains($error, 'The last posting from your IP'); + } +} diff --git a/tests/Integration/Http/LoginTest.php b/tests/Integration/Http/LoginTest.php new file mode 100644 index 00000000000..48b7ba77555 --- /dev/null +++ b/tests/Integration/Http/LoginTest.php @@ -0,0 +1,99 @@ +<?php + +declare(strict_types=1); + +namespace SMF\Tests\Integration\Http; + +use PHPUnit\Framework\Attributes\CoversNothing; +use SMF\Config; + +/** + * Signing in over HTTP. + * + * Worth testing this way rather than through User::setMe(), which is what the + * rest of the integration suite uses: the parts most likely to break are exactly + * the ones setMe() skips. The session has to survive between requests, the + * security token minted with the form has to still be valid when it comes back, + * the cookie has to be signed with $auth_secret, and a post that arrives without + * a session check has to be turned away. + */ +#[CoversNothing] +class LoginTest extends HttpTestCase +{ + /**************** + * Public methods + ****************/ + + public function testTheAdministratorCanSignIn(): void + { + $this->signInAsAdmin(); + + $this->assertSignedIn(true, 'the session did not survive the login redirect'); + $this->assertNoErrorsLogged('signing in logged something.' . "\n"); + } + + /** + * The cookie is what carries the login between requests, so it is worth + * checking it was issued rather than inferring it from the page changing. + */ + public function testSigningInIssuesTheForumCookie(): void + { + $response = $this->signInAsAdmin(); + + $this->assertNotEmpty($response->set_cookies, 'logging in set no cookie at all'); + + // Note the plural: the response carries the session cookie as well, and + // keeping only the last one would test whichever happened to come second. + $this->assertStringContainsString( + (string) Config::$cookiename, + implode("\n", $response->set_cookies), + 'the forum cookie was not among those set: ' . implode(' | ', $response->set_cookies), + ); + } + + public function testTheWrongPasswordDoesNotSignAnyoneIn(): void + { + $form = $this->fetch('?action=login'); + + $this->http->submit($form, [ + 'user' => self::adminName(), + 'passwrd' => 'definitely not the password', + ], '//form[contains(@action, "action=login2")]'); + + $this->assertSignedIn(false, 'a wrong password signed us in anyway'); + } + + /** + * A post carrying no session check should be turned away. This is the guard + * that stops another site from posting to the forum on a visitor's behalf, + * and nothing that does not go over HTTP can exercise it. + */ + public function testAPostWithoutTheSessionCheckIsRejected(): void + { + // Hand built rather than submitted from the form, so none of the session + // fields the form carries are included. + $this->http->post('?action=login2', [ + 'user' => self::adminName(), + 'passwrd' => self::adminPassword(), + ]); + + $this->assertSignedIn(false, 'a login with no session check was accepted'); + } + + public function testSigningOutEndsTheSession(): void + { + $this->signInAsAdmin(); + $this->assertSignedIn(true); + + $page = $this->fetch(''); + $logout = $page->xpath('//a[contains(@href, "action=logout")]')->item(0); + + $this->assertNotNull($logout, 'no logout link to follow'); + + // The link carries its own session check in the query string. + $this->http->get((string) $logout?->attributes?->getNamedItem('href')?->nodeValue); + + $this->assertSignedIn(false, 'still signed in after logging out'); + $this->assertNoErrorsLogged('logging out logged something.' . "\n"); + } +} diff --git a/tests/Integration/Http/PostingTest.php b/tests/Integration/Http/PostingTest.php new file mode 100644 index 00000000000..8240481b32c --- /dev/null +++ b/tests/Integration/Http/PostingTest.php @@ -0,0 +1,222 @@ +<?php + +declare(strict_types=1); + +namespace SMF\Tests\Integration\Http; + +use PHPUnit\Framework\Attributes\CoversNothing; +use SMF\Topic; + +/** + * Starting a topic and replying to it, over HTTP. + * + * This is the journey the forum exists for, and the one with the most behind it: + * the editor, the session check, the security token, permissions, the post + * itself, and then every counter and index SMF updates afterwards. Nothing short + * of a real request covers that. + * + * These tests write, and an HTTP test cannot be rolled back - the request runs in + * the web server's process on its own connection. So whatever they create, they + * remove again in tearDown through SMF's own Topic::remove(), which puts the + * board and member counts back the way deleting a topic in the browser would. + */ +#[CoversNothing] +class PostingTest extends HttpTestCase +{ + /********************* + * Internal properties + *********************/ + + /** + * @var array Topics this test created, to be removed afterwards. + */ + private array $created_topics = []; + + /**************** + * Public methods + ****************/ + + public function testStartingATopicAndReplyingToIt(): void + { + $this->signInAsAdmin(); + + $subject = 'Integration test topic ' . bin2hex(random_bytes(6)); + $body = 'Posted by the integration suite at ' . date('c') . '.'; + + $topic_id = $this->startTopic($subject, $body); + + $topic = $this->fetch('?topic=' . $topic_id . '.0'); + + $this->assertStringContainsString( + $subject, + $topic->text(), + 'the new topic does not show its own subject', + ); + + $this->assertStringContainsString($body, $topic->text(), 'the post body is missing'); + + // And now a reply, which goes through a different form on a different + // page and updates a different set of counters. + $reply = 'A reply from the integration suite.'; + + $form = $this->fetch('?action=post;topic=' . $topic_id . '.0'); + + $posted = $this->submitForm($form, [ + 'subject' => 'Re: ' . $subject, + 'message' => $reply, + 'post' => 'Post', + ], '//form[contains(@action, "action=post2")]'); + + $this->assertLessThan( + 400, + $posted->status, + 'replying returned ' . $posted->status . ': ' . $posted->errorText(), + ); + + $after = $this->fetch('?topic=' . $topic_id . '.0'); + + $this->assertStringContainsString($reply, $after->text(), 'the reply is not on the topic'); + + $this->assertSame( + 2, + $this->countMessages($topic_id), + 'the topic should hold the first post and the reply', + ); + + $this->assertNoErrorsLogged('posting logged something.' . "\n"); + } + + /** + * A guest cannot post on a stock install, and the forum should say so rather + * than accept it. + */ + public function testAGuestCannotStartATopic(): void + { + $before = $this->countTopicsInBoard(1); + + $this->http->post('?action=post2;board=1', [ + 'subject' => 'Integration test guest post', + 'message' => 'This should not be accepted.', + ]); + + $this->assertSame( + $before, + $this->countTopicsInBoard(1), + 'a guest with no session check managed to start a topic', + ); + } + + /****************** + * Internal methods + ******************/ + + protected function tearDown(): void + { + // Before the parent runs, while the connection is still ours. + if ($this->created_topics !== []) { + Topic::remove($this->created_topics); + + $this->created_topics = []; + } + + parent::tearDown(); + } + + /** + * Starts a topic in the first board and returns its id. + * + * @param string $subject The subject. + * @param string $body The message. + * @return int The new topic's id. + */ + private function startTopic(string $subject, string $body): int + { + $before = $this->latestTopicId(); + + $form = $this->fetch('?action=post;board=1.0'); + + $this->assertGreaterThan( + 0, + $form->xpath('//form[contains(@action, "action=post2")]')->length, + 'there is no posting form on the new topic page', + ); + + $posted = $this->submitForm($form, [ + 'subject' => $subject, + 'message' => $body, + // The button we are pressing. Without it the form's other button, + // "preview", is the one SMF acts on. + 'post' => 'Post', + ], '//form[contains(@action, "action=post2")]'); + + $this->assertLessThan( + 400, + $posted->status, + 'posting returned ' . $posted->status . ': ' . $posted->errorText(), + ); + + $topic_id = $this->latestTopicId(); + + // Quote the page when this fails. SMF answers a rejected post with a + // perfectly ordinary 200 and the reason in a box, so without this the + // only evidence is a topic id that did not move. + $this->assertGreaterThan( + $before, + $topic_id, + 'no new topic appeared after posting. The forum said: ' + . ($posted->errorText() !== '' ? $posted->errorText() : '(nothing) - page title "' . $posted->title() . '"'), + ); + + $this->created_topics[] = $topic_id; + + return $topic_id; + } + + /** + * The highest topic id in the forum. + * + * @return int The id, or 0 when there are no topics. + */ + private function latestTopicId(): int + { + $row = $this->queryRow('SELECT COALESCE(MAX(id_topic), 0) AS id FROM {db_prefix}topics'); + + return (int) ($row['id'] ?? 0); + } + + /** + * How many messages a topic holds. + * + * @param int $topic_id The topic. + * @return int The number of messages. + */ + private function countMessages(int $topic_id): int + { + $row = $this->queryRow( + 'SELECT COUNT(*) AS total + FROM {db_prefix}messages + WHERE id_topic = {int:topic}', + ['topic' => $topic_id], + ); + + return (int) ($row['total'] ?? 0); + } + + /** + * How many topics a board holds. + * + * @param int $board_id The board. + * @return int The number of topics. + */ + private function countTopicsInBoard(int $board_id): int + { + $row = $this->queryRow( + 'SELECT COUNT(*) AS total + FROM {db_prefix}topics + WHERE id_board = {int:board}', + ['board' => $board_id], + ); + + return (int) ($row['total'] ?? 0); + } +} diff --git a/tests/Integration/Http/index.php b/tests/Integration/Http/index.php new file mode 100644 index 00000000000..2844a3b9e7b --- /dev/null +++ b/tests/Integration/Http/index.php @@ -0,0 +1,8 @@ +<?php + +// Try to handle it with the upper level index.php. (it should know what to do.) +if (file_exists(dirname(__DIR__) . DIRECTORY_SEPARATOR . 'index.php')) { + include dirname(__DIR__) . DIRECTORY_SEPARATOR . 'index.php'; +} else { + exit; +} diff --git a/tests/Integration/IntegrationTestCase.php b/tests/Integration/IntegrationTestCase.php index 3475c80dd33..b540e9158ee 100644 --- a/tests/Integration/IntegrationTestCase.php +++ b/tests/Integration/IntegrationTestCase.php @@ -62,11 +62,29 @@ public static function setUpBeforeClass(): void * Internal methods ******************/ + /** + * Whether to wrap the test in a transaction that is rolled back afterwards. + * + * Override and return false when the test causes work to happen in another + * process - a request made over HTTP, say. That runs on its own connection, + * so the transaction cannot undo it, and on MySQL, whose default isolation + * level is REPEATABLE READ, this connection would go on reading the snapshot + * it took before the request and never see what the request wrote. + * + * @return bool True to use a transaction, which is what most tests want. + */ + protected function usesTransaction(): bool + { + return true; + } + protected function setUp(): void { parent::setUp(); - Db::$db->transaction('begin'); + if ($this->usesTransaction()) { + Db::$db->transaction('begin'); + } // $modSettings is a plain static array, so a test that calls // updateModSettings() changes it for everything that runs after it. The @@ -78,7 +96,9 @@ protected function setUp(): void protected function tearDown(): void { - Db::$db->transaction('rollback'); + if ($this->usesTransaction()) { + Db::$db->transaction('rollback'); + } Config::$modSettings = $this->mod_settings_backup; diff --git a/tests/Support/HttpClient.php b/tests/Support/HttpClient.php new file mode 100644 index 00000000000..be674df3ca3 --- /dev/null +++ b/tests/Support/HttpClient.php @@ -0,0 +1,361 @@ +<?php + +declare(strict_types=1); + +namespace SMF\Tests\Support; + +use SMF\Config; + +/** + * A very small browser, for driving the forum over HTTP. + * + * Requests have to be real ones. Utils::obExit(), redirectexit(), + * serverResponse() and ErrorHandler::fatal*() all end in exit, and Db::$db, + * ActionTrait::$obj, Theme::$loaded and User::$loaded have no way to be reset, so + * a test process can carry out exactly one request in itself and no more. Going + * over the wire sidesteps all of that and exercises the same path a visitor does, + * including the session, the cookies and the theme. + * + * Uses curl through the extension the forum already requires, so it costs no new + * dependency. + */ +final class HttpClient +{ + /******************* + * Public properties + *******************/ + + /** + * @var string Where requests go. Public so a test can report it on failure. + */ + public readonly string $base_url; + + /********************* + * Internal properties + *********************/ + + /** + * @var \CurlHandle One handle for the life of the client. + * + * Reused rather than opened per request, and that is load bearing. curl only + * writes cookies that carry an expiry to the jar file; a session cookie has + * none, so closing the handle between requests threw the session away and + * every request arrived as a brand new visitor. The symptom is not an obvious + * one - pages still render, but any POST is rejected because the session + * check and the security token it carries were issued to a session that no + * longer exists. + */ + private \CurlHandle $handle; + + /** + * @var string Path to this client's cookie jar. + */ + private string $jar; + + /** + * @var HttpResponse|null The most recent response. + */ + private ?HttpResponse $last = null; + + /**************** + * Public methods + ****************/ + + /** + * @param string|null $base_url Override the forum URL to talk to. + */ + public function __construct(?string $base_url = null) + { + $this->base_url = rtrim($base_url ?? self::detectBaseUrl(), '/'); + + $this->jar = (string) tempnam(sys_get_temp_dir(), 'smf_tests_cookies_'); + + $handle = curl_init(); + + if (!$handle instanceof \CurlHandle) { + throw new \RuntimeException('could not start curl'); + } + + $this->handle = $handle; + } + + public function __destruct() + { + curl_close($this->handle); + + if ($this->jar !== '' && is_file($this->jar)) { + @unlink($this->jar); + } + } + + /** + * Fetches a page. + * + * @param string $path Either a full URL or something to hang off the board + * URL, with or without a leading slash. '?action=login' is typical. + * @return HttpResponse The response. + */ + public function get(string $path = ''): HttpResponse + { + return $this->request($this->url($path), null); + } + + /** + * Posts to a page. + * + * @param string $path Where to post, as for get(). + * @param array $fields The form fields. + * @return HttpResponse The response. + */ + public function post(string $path, array $fields): HttpResponse + { + return $this->request($this->url($path), $fields); + } + + /** + * Submits a form on a page the client has already fetched. + * + * This is the method to reach for. SMF forms carry a session check that + * User::checkSession() rejects the request without, and often a SecurityToken + * as well, both named unpredictably per session; resubmitting every field the + * page offered is what a browser does and saves the test knowing about any of + * it. + * + * @param HttpResponse $page The page holding the form. + * @param array $overrides Values to change or add. + * @param string $xpath Which form. Defaults to the first on the page. + * @return HttpResponse The response. + */ + public function submit(HttpResponse $page, array $overrides = [], string $xpath = '//form'): HttpResponse + { + return $this->request( + $this->url($page->formAction($xpath)), + array_merge($page->formFields($xpath), $overrides), + ); + } + + /** + * The most recent response, for reporting on a failure. + * + * @return HttpResponse|null The response, or null if nothing has been sent. + */ + public function lastResponse(): ?HttpResponse + { + return $this->last; + } + + /** + * Throws away this client's cookies, making it a fresh visitor. + */ + public function forgetCookies(): void + { + // In memory, not in the file: session cookies never reach the file. + curl_setopt($this->handle, CURLOPT_COOKIELIST, 'ALL'); + + if (is_file($this->jar)) { + file_put_contents($this->jar, ''); + } + } + + /****************** + * Internal methods + ******************/ + + /** + * Turns whatever a caller passed into an absolute URL. + * + * @param string $path A full URL, a query string, or a path. + * @return string An absolute URL. + */ + private function url(string $path): string + { + if ($path === '') { + return $this->base_url . '/'; + } + + // A form action is usually an absolute URL built from Config::$boardurl, + // which is not necessarily the host we are talking to - inside the + // container the forum answers on port 80 while boardurl names 8080. Keep + // the path and query, drop the rest. + if (preg_match('~^https?://~i', $path)) { + $parts = parse_url($path); + + $path = ($parts['path'] ?? '/') + . (isset($parts['query']) ? '?' . $parts['query'] : '') + . (isset($parts['fragment']) ? '#' . $parts['fragment'] : ''); + } + + if (str_starts_with($path, '?')) { + return $this->base_url . '/index.php' . $path; + } + + return $this->base_url . '/' . ltrim($path, '/'); + } + + /** + * Sends one request. + * + * @param string $url The absolute URL. + * @param array|null $fields POST fields, or null for a GET. + * @return HttpResponse The response. + */ + private function request(string $url, ?array $fields): HttpResponse + { + $handle = $this->handle; + + $options = [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HEADER => true, + // Off on purpose. A redirect is frequently the thing under test - + // posting a reply is a success only if it sends you somewhere - and + // following it silently would hide both the status and the location. + CURLOPT_FOLLOWLOCATION => false, + CURLOPT_COOKIEJAR => $this->jar, + CURLOPT_COOKIEFILE => $this->jar, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_TIMEOUT => 30, + CURLOPT_USERAGENT => 'SMF test suite', + ]; + + if ($fields !== null) { + $options[CURLOPT_POST] = true; + $options[CURLOPT_POSTFIELDS] = http_build_query($fields); + } else { + // The handle is reused, so a GET after a POST has to say so or it + // would repeat the previous body. + $options[CURLOPT_HTTPGET] = true; + } + + curl_setopt_array($handle, $options); + + $raw = curl_exec($handle); + + if ($raw === false) { + throw new \RuntimeException('request to ' . $url . ' failed: ' . curl_error($handle)); + } + + $status = (int) curl_getinfo($handle, CURLINFO_HTTP_CODE); + $header_size = (int) curl_getinfo($handle, CURLINFO_HEADER_SIZE); + + $raw = (string) $raw; + + [$headers, $set_cookies] = self::parseHeaders(substr($raw, 0, $header_size)); + + return $this->last = new HttpResponse( + $status, + substr($raw, $header_size), + $headers, + $url, + $set_cookies, + ); + } + + /************************* + * Internal static methods + *************************/ + + /** + * Works out which URL the forum answers on. + * + * SMF_TESTS_BASE_URL wins. Otherwise it is Config::$boardurl, unless nothing + * is listening there - which is the normal case when the tests run inside the + * web container, where the forum is on port 80 and boardurl names whatever + * port the host publishes. + * + * @return string The base URL. + */ + private static function detectBaseUrl(): string + { + $override = (string) getenv('SMF_TESTS_BASE_URL'); + + if ($override !== '') { + return $override; + } + + $boardurl = (string) (Config::$boardurl ?? ''); + + if ($boardurl !== '' && self::listening($boardurl)) { + return $boardurl; + } + + $parts = parse_url($boardurl) ?: []; + + return ($parts['scheme'] ?? 'http') . '://localhost' . ($parts['path'] ?? ''); + } + + /** + * Whether anything answers on the host and port of a URL. + * + * @param string $url The URL to try. + * @return bool Whether a connection could be opened. + */ + private static function listening(string $url): bool + { + $parts = parse_url($url); + + if (!isset($parts['host'])) { + return false; + } + + $port = $parts['port'] ?? (($parts['scheme'] ?? 'http') === 'https' ? 443 : 80); + + $socket = @fsockopen($parts['host'], (int) $port, $errno, $errstr, 2); + + if ($socket === false) { + return false; + } + + fclose($socket); + + return true; + } + + /** + * Splits a raw header block into name => value. + * + * Only the last set is kept, which matters because CURLOPT_HEADER includes + * every hop when a proxy or a 100-continue is involved. + * + * @param string $raw The raw headers. + * @return array Two items: the headers as lowercased name => value, and + * every Set-Cookie value in order. + */ + private static function parseHeaders(string $raw): array + { + $headers = []; + $cookies = []; + + foreach (preg_split('~\R~', $raw) ?: [] as $line) { + $line = trim($line); + + if ($line === '') { + continue; + } + + if (stripos($line, 'HTTP/') === 0) { + $headers = []; + $cookies = []; + + continue; + } + + $colon = strpos($line, ':'); + + if ($colon === false) { + continue; + } + + $name = strtolower(substr($line, 0, $colon)); + $value = trim(substr($line, $colon + 1)); + + $headers[$name] = $value; + + if ($name === 'set-cookie') { + $cookies[] = $value; + } + } + + return [$headers, $cookies]; + } +} diff --git a/tests/Support/HttpResponse.php b/tests/Support/HttpResponse.php new file mode 100644 index 00000000000..15b844577e6 --- /dev/null +++ b/tests/Support/HttpResponse.php @@ -0,0 +1,259 @@ +<?php + +declare(strict_types=1); + +namespace SMF\Tests\Support; + +/** + * One response from HttpClient. + * + * Parsing is deliberately DOM based rather than string matching. The theme moves + * around a lot, so a test that greps for a phrase fails the next time somebody + * reflows the markup, which teaches everyone to ignore it. + */ +final class HttpResponse +{ + /********************* + * Internal properties + *********************/ + + /** + * @var \DOMDocument|null The parsed body, once something has asked for it. + */ + private ?\DOMDocument $dom = null; + + /**************** + * Public methods + ****************/ + + /** + * @param int $status The HTTP status. + * @param string $body The response body. + * @param array $headers Headers, lowercased name => value. Where a header + * appeared more than once only the last is here; Set-Cookie is the one + * that routinely does, so it has its own list below. + * @param string $url The URL that was requested. + * @param array $set_cookies Every Set-Cookie header, in the order sent. + * Logging in sends two - the session and the forum's own - and keeping + * only one of them loses whichever the test cares about. + */ + public function __construct( + public readonly int $status, + public readonly string $body, + public readonly array $headers, + public readonly string $url, + public readonly array $set_cookies = [], + ) {} + + /** + * The response body as a DOM document. + * + * SMF emits HTML5, which DOMDocument grumbles about; the warnings are not + * interesting and would fail the test under failOnWarning, so they are + * collected and discarded rather than raised. + * + * Parsed once per response. Note the cache has to be a property: a static + * inside this method is shared by every instance of the class, so the first + * page fetched would be handed back for every page after it. + * + * @return \DOMDocument The parsed body. + */ + public function dom(): \DOMDocument + { + if ($this->dom instanceof \DOMDocument) { + return $this->dom; + } + + $dom = new \DOMDocument(); + + $previous = libxml_use_internal_errors(true); + $dom->loadHTML('<?xml encoding="UTF-8">' . $this->body, LIBXML_NOWARNING | LIBXML_NOERROR); + libxml_clear_errors(); + libxml_use_internal_errors($previous); + + return $this->dom = $dom; + } + + /** + * Runs an XPath query against the body. + * + * @param string $expression The XPath expression. + * @return \DOMNodeList The matching nodes. + */ + public function xpath(string $expression): \DOMNodeList + { + $result = (new \DOMXPath($this->dom()))->query($expression); + + return $result === false ? new \DOMNodeList() : $result; + } + + /** + * The page title, without the forum name SMF appends to it. + * + * @return string The title, or an empty string when there is none. + */ + public function title(): string + { + $titles = $this->xpath('//title'); + + return $titles->length === 0 ? '' : trim((string) $titles->item(0)?->textContent); + } + + /** + * All visible text, with runs of whitespace collapsed. + * + * For assertions where the structure genuinely does not matter, such as + * checking an error message reached the page at all. + * + * @return string The text content of the body. + */ + public function text(): string + { + $body = $this->xpath('//body'); + $text = $body->length === 0 ? $this->body : (string) $body->item(0)?->textContent; + + return trim((string) preg_replace('~\s+~u', ' ', $text)); + } + + /** + * Whatever the page is complaining about. + * + * SMF renders a fatal error as an ordinary page with a box on it, and the + * surrounding menus and news run to several hundred characters, so quoting + * the start of the body in a failure message reliably shows everything + * except the reason. This picks out the reason. + * + * @return string The error text, or an empty string when there is none. + */ + public function errorText(): string + { + $found = []; + + foreach ($this->xpath('//*[contains(@class, "errorbox")] | //*[contains(@class, "error_message")] | //*[@id="fatal_error"]') as $node) { + $text = trim((string) preg_replace('~\s+~u', ' ', $node->textContent)); + + if ($text !== '') { + $found[] = $text; + } + } + + return implode(' / ', array_unique($found)); + } + + /** + * Every field a browser would submit for the given form. + * + * SMF puts more than one hidden field in its forms - the session check that + * User::checkSession() insists on, and often a SecurityToken as well - and + * the names of both are generated per session. Collecting them all is both + * simpler and more honest than knowing which is which. + * + * Buttons are left out, because a browser submits only the one that was + * clicked and SMF branches on which that was. The posting form offers both + * "preview" and "post"; sending the pair means the preview wins and the reply + * is silently never made, with a perfectly good 200 to show for it. Pass the + * button you mean to press as an override. + * + * @param string $xpath Which form. Defaults to the first one on the page. + * @return array The fields, name => value. + */ + public function formFields(string $xpath = '//form'): array + { + $form = $this->xpath($xpath)->item(0); + + if (!$form instanceof \DOMElement) { + return []; + } + + $fields = []; + $finder = new \DOMXPath($this->dom()); + + foreach ($finder->query('.//input | .//textarea | .//select', $form) ?: [] as $input) { + if (!$input instanceof \DOMElement) { + continue; + } + + $name = $input->getAttribute('name'); + + if ($name === '') { + continue; + } + + $type = strtolower($input->getAttribute('type')); + + // A browser only submits these when they are ticked, and submitting + // an unticked one turns every checkbox on the page into a yes. + if (\in_array($type, ['checkbox', 'radio'], true) && !$input->hasAttribute('checked')) { + continue; + } + + // Only the button that was clicked gets submitted. See above. + if (\in_array($type, ['submit', 'button', 'reset', 'image'], true)) { + continue; + } + + $fields[$name] = match ($input->nodeName) { + 'textarea' => $input->textContent, + 'select' => $this->selectedOption($finder, $input), + default => $input->getAttribute('value'), + }; + } + + return $fields; + } + + /** + * Where the given form posts to. + * + * @param string $xpath Which form. Defaults to the first one on the page. + * @return string The action attribute, or the current URL when it has none. + */ + public function formAction(string $xpath = '//form'): string + { + $form = $this->xpath($xpath)->item(0); + + if (!$form instanceof \DOMElement) { + return $this->url; + } + + $action = $form->getAttribute('action'); + + return $action === '' ? $this->url : $action; + } + + /****************** + * Internal methods + ******************/ + + /** + * The value a browser would submit for a select element. + * + * @param \DOMXPath $finder An XPath instance for this document. + * @param \DOMElement $select The select element. + * @return string The selected value, or the first option's. + */ + private function selectedOption(\DOMXPath $finder, \DOMElement $select): string + { + $options = $finder->query('.//option', $select) ?: new \DOMNodeList(); + + $first = ''; + + foreach ($options as $option) { + if (!$option instanceof \DOMElement) { + continue; + } + + $value = $option->hasAttribute('value') ? $option->getAttribute('value') : $option->textContent; + + if ($first === '') { + $first = $value; + } + + if ($option->hasAttribute('selected')) { + return $value; + } + } + + return $first; + } +} diff --git a/tests/Support/index.php b/tests/Support/index.php new file mode 100644 index 00000000000..2844a3b9e7b --- /dev/null +++ b/tests/Support/index.php @@ -0,0 +1,8 @@ +<?php + +// Try to handle it with the upper level index.php. (it should know what to do.) +if (file_exists(dirname(__DIR__) . DIRECTORY_SEPARATOR . 'index.php')) { + include dirname(__DIR__) . DIRECTORY_SEPARATOR . 'index.php'; +} else { + exit; +} From 5df86c64738e8bbff1153810e7d06693dd61f947 Mon Sep 17 00:00:00 2001 From: albertlast <mathiaspapealbert@hotmail.com> Date: Sun, 2 Aug 2026 20:51:39 +0200 Subject: [PATCH 24/26] Documents how to write an HTTP test The suite ships the machinery but not the instructions for using it, and three things are not discoverable from reading it: which of the three suites a new test belongs in, who the request is actually made as, and where the endpoint and field names come from. The second is the one that misleads. HttpTestCase inherits actingAs() from IntegrationTestCase, where it repoints User::$me in the PHPUnit process - but the request is handled by Apache in another process, which knows only the cookie. Calling it in an HTTP test changes nothing and leaves the assertions describing a guest, confidently. The identity of a request here is the cookie jar and nothing else. Field names are the opposite problem: they look like something to look up, and are not. submit() scrapes the form the way a browser does, which is what carries the session check and the security token - both named differently for every session, so a hand built POST body gets a 403 it cannot fix. The worked example prints them to make that concrete. Also notes that install.php left in the board root puts an errorbox on every page an administrator sees, which fails assertLooksLikeAForumPage() and crowds out whatever the test was looking at. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: albertlast <mathiaspapealbert@hotmail.com> --- .docker/README.md | 155 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) diff --git a/.docker/README.md b/.docker/README.md index 3b5126423f9..64c2b8b13d9 100644 --- a/.docker/README.md +++ b/.docker/README.md @@ -143,6 +143,161 @@ Getting that wrong makes those tests **skip**, with a message saying so, rather than fail — a password the suite does not know is a misconfigured forum, not a regression. +## Writing a test + +### Which suite + +Three of them, and picking the wrong one is the usual reason a test is harder to +write than it should be: + +| Suite | Has | Use it for | +| ------------------------ | -------------------------------------- | ----------------------------- | +| `tests/Unit` | nothing — no database, no request | pure functions, value objects | +| `tests/Integration` | `Db::$db`, `$modSettings`, `User::$me` | anything needing real data | +| `tests/Integration/Http` | all of that, plus a real request | proving a *page* works | + +Work down the list and stop at the first that can hold the test. An HTTP test +costs about a second and cannot be rolled back; a unit test costs nothing. The +limits of the unit suite are spelled out in `AGENTS.md`. + +Reach for HTTP only when the thing worth proving is in the parts nothing else +touches: the session, the cookies, the security token, the theme and the +templates. `User::setMe()` skips every one of them, which is exactly why the +plain integration tests are cheap. + +### The shape of an HTTP test + +Four beats: fetch a page, submit a form on it, assert on what came back, assert +nothing was logged. + +```php +#[CoversNothing] +class ProfileTest extends HttpTestCase +{ + public function testAMemberCanChangeTheirSignature(): void + { + $this->signInAsAdmin(); + + $form = $this->fetch('?action=profile;area=forumprofile'); + + $response = $this->submitForm($form, [ + 'signature' => 'Set by the integration suite.', + 'save' => 'Change profile', // the button + ], '//form[contains(@action, "area=forumprofile")]'); + + $this->assertLessThan(400, $response->status, $response->errorText()); + $this->assertNoErrorsLogged('saving a signature logged something.' . "\n"); + } +} +``` + +`#[CoversNothing]` is not optional. These cross dozens of classes, so naming one +would be untrue, and `failOnRisky` wants an attribute either way. + +Four things that are easy to get wrong: + +- **Submit through `submitForm()`, not `HttpClient::submit()`.** Only the former + waits out flood control. `Security::spamProtection()` gives a moderator two + seconds between posts, per IP, and the tests all arrive from the same one far + faster than a person would; without the wait you get a suite that fails about + one run in five for no reproducible reason. +- **Name the button you are pressing.** `formFields()` leaves every button out on + purpose, because the posting form carries both `preview` and `post` and sending + the pair means preview quietly wins — no post, and a perfectly good 200 to show + for it. +- **Clean up whatever you write.** There is no transaction here; see + `HttpTestCase::usesTransaction()` for why one would not help. `PostingTest` + deletes through `Topic::remove()` rather than by hand, so the board and member + counters go back as well. +- **`assertNoErrorsLogged()` is the point of the test**, not a formality. A page + can return exactly the right HTML while logging an undefined index, and that is + the failure mode this whole suite exists to catch. + +One thing to rule out before believing a failure: if `install.php` is still in +the board root, SMF puts a "MAJOR SECURITY RISK" box on every page it shows an +administrator. That is an `errorbox`, so it fails `assertLooksLikeAForumPage()` +and turns up in `errorText()` in front of whatever the test was actually looking +at. `install-forum.sh` removes the file once it is done; a forum installed +through the browser needs it deleting by hand. + +### Who the request is + +The identity of an HTTP request is the cookie jar and nothing else. There are two +states out of the box: a guest, which is what `setUp()` leaves you, and the +administrator, through `signInAsAdmin()`. + +**`actingAs()` does not work here.** It is inherited from `IntegrationTestCase` +and it repoints `User::$me` in the PHPUnit process — but the request is handled +by Apache in a different process, which knows only the cookie. Calling it in an +HTTP test changes nothing about the request and leaves the assertions describing +a guest, confidently. + +So: + +- **Two users at once** means two `HttpClient` instances. Each opens its own + cookie jar, so they are independent browsers — which is how to test one member + sending another a PM. +- **Back to being a guest** is `$this->http->forgetCookies()`, then + `$this->http->get('')` to pick up a fresh session. +- **A member who is not the administrator** has to be made first, through + `Register2::registerMember()` with `interface => 'admin'` (which needs + `actingAs($this->adminId())` first, as it checks `moderate_forum`). That member + outlives the test, so delete it in `tearDown()`. + +### Finding the endpoint and the field names + +Endpoints are looked up; field names are not. + +`Forum::$actions` in `Sources/Forum.php` is the authoritative list of every +`?action=` the forum answers and the class behind it. Sub-actions — the `;area=` +and `;sa=` parts — are a `$subactions` property on that class. So +`?action=profile;area=forumprofile` resolves as `$actions['profile']` → +`Actions\Profile\Main` → its `$subactions`. That is quicker and more reliable +than reading templates. + +Field names you are deliberately not meant to know. `HttpClient::submit()` +scrapes every input, textarea and select out of the form it was handed and sends +them back, the way a browser does. That is what carries the session check and the +security token, both named unpredictably per session and neither hardcodable. All +a test supplies is the few values it is choosing, plus the button. + +When you do need to see them, ask the page rather than the template: + +```sh +docker compose exec web php -r ' + require "tests/bootstrap.php"; + $c = new SMF\Tests\Support\HttpClient(); + $c->get(""); + $p = $c->get("?action=login"); + print_r($p->formFields("//form[contains(@action, \"login2\")]"));' +``` + +``` +Array +( + [user] => + [passwrd] => + [d0004e1655] => b2f5189a9b3014cadee7bcb0b8d697f2 + [b8ae8fd32d] => 8f6026ed9a1b91bf6fec315801a2a93c +) +``` + +Two named fields, which are the ones a test writes, and two whose names are +different for every session — the session check and the security token, and +running the command twice gives two different pairs. That is what +`submit()` is for, and why a test that builds its own POST body by hand gets a +403 it cannot fix. + +The paths are relative because the container's working directory is +`/var/www/html` already. Spelling them absolutely also works, but not from Git +Bash on Windows, which rewrites anything that looks like a Unix path before +Docker sees it. + +Note the throwaway `get("")` before the form is fetched. The very first request +of a new session regenerates it, so a token minted on the first page a visitor +ever sees is bound to a session that no longer exists by the time it comes back. +The symptom is a 403 about the token, when the token was never the problem. + ### Installing in a browser instead On first boot the entrypoint writes a `Settings.php` pre-filled for the chosen From c7b9f5a5c656685c7abddf49f8c76800067a2ea5 Mon Sep 17 00:00:00 2001 From: albertlast <mathiaspapealbert@hotmail.com> Date: Sun, 2 Aug 2026 21:12:46 +0200 Subject: [PATCH 25/26] Adds a script for checking and resetting account passwords Two forums side by side, each with its own administrator, and a password chosen at install time is a combination that ends in hand written SQL sooner or later - which is a poor way to answer a question as ordinary as "is this the password?". user.sh answers it. list shows the accounts, check says whether SMF would accept a password and exits 0 or 1 so it can be used in a conditional, and reset sets a new one. --engine reads the settings use-engine.sh saved for the other engine, so the forum that is not currently live can be looked at without switching to it and back. Two details that stop it being a thin wrapper around an UPDATE: - The hashing goes through Security::hashPassword() rather than being written here, so what lands in the table is by construction what Login2 reads back out. A script that hashes passwords its own way is a script that eventually disagrees with the forum. - reset clears passwd_flood too. SMF locks an account out for a while after enough wrong guesses, and a new password behind a live lockout behaves exactly like a password that did not take. check also points out an account that is not activated, which fails to log in with an entirely correct password. The password is passed to the container through the environment rather than in the argument list, which anything able to read the process table can see. Also completes the file list in the README, which still only described the image and had none of the scripts in it. Signed-off-by: albertlast <mathiaspapealbert@hotmail.com> --- .docker/README.md | 35 ++++++++ .docker/user.sh | 204 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 239 insertions(+) create mode 100755 .docker/user.sh diff --git a/.docker/README.md b/.docker/README.md index 57afbed0550..aac1648fae7 100644 --- a/.docker/README.md +++ b/.docker/README.md @@ -110,6 +110,35 @@ are gitignored. installer, discarding that forum. `use-engine.sh` switches between forums, `reset.sh` throws one away. +## Accounts and passwords + +Two forums, each with its own administrator, and a password chosen months ago is +a recipe for an afternoon of hand written SQL. `user.sh` is there so it is not: + +```sh +.docker/user.sh list +.docker/user.sh check admin 'password' +.docker/user.sh reset admin 'a new password' +``` + +`check` exits 0 when SMF would accept the password and 1 when it would not, so +it works in a conditional as well as by eye. It also points out an account that +is not activated, which fails to log in with a correct password and looks +exactly like a wrong one. + +`--engine mysql|postgresql` reads the settings `use-engine.sh` saved for that +engine, so the *other* forum can be inspected without switching to it: + +```sh +.docker/user.sh check admin 'password' --engine mysql +``` + +The hashing goes through SMF's own `Security` class rather than being written +here, so what `reset` puts in the table is by construction what `Login2` expects +to find. It clears `passwd_flood` at the same time: SMF locks an account out for +a while after enough wrong guesses, and a fresh password behind a lockout looks +exactly like a password that did not take. + ### Installing in a browser instead On first boot the entrypoint writes a `Settings.php` pre-filled for the chosen @@ -229,4 +258,10 @@ compose.yaml the stack .docker/mysql/init/10-smf.sh runs once on first mysql database creation .docker/postgres/init/10-smf.sh runs once on first postgres database creation .docker/env.example optional overrides + +.docker/lib.sh paths, credentials and engine names, shared +.docker/install-forum.sh install a forum with no browser involved +.docker/reset.sh empty one engine and restage the installer +.docker/use-engine.sh switch which installed forum is live +.docker/user.sh inspect accounts, check and reset passwords ``` diff --git a/.docker/user.sh b/.docker/user.sh new file mode 100755 index 00000000000..eebf4d1a8e5 --- /dev/null +++ b/.docker/user.sh @@ -0,0 +1,204 @@ +#!/usr/bin/env bash +# Looks at forum accounts and fixes their passwords, so "which password did this +# forum end up with?" does not turn into a session of hand written SQL. +# +# .docker/user.sh list +# .docker/user.sh check admin 'password' +# .docker/user.sh reset admin 'a new password' +# .docker/user.sh check admin 'password' --engine postgresql +# +# check exits 0 when SMF would accept the password and 1 when it would not, so +# it is usable in a conditional as well as by eye. +# +# Everything goes through SMF's own Security class rather than writing a hash +# from here: what this puts in the table is by construction what Login2 expects +# to find there. Nothing is ever printed that would reveal an existing password; +# hashes are one way and this does not try to be clever about that. +# +# Without --engine it acts on the forum Settings.php currently points at. With +# it, it reads the copy use-engine.sh saved for that engine instead, which means +# the other forum can be inspected without switching to it. +# +# Runs on the host. +set -euo pipefail + +. "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh" + +ENGINE='' +ACTION='' +NAME='' +PASSWORD='' +POSITIONAL=() + +while [ $# -gt 0 ]; do + case "$1" in + --engine) ENGINE="$2"; shift 2 ;; + --engine=*) ENGINE="${1#*=}"; shift ;; + -h|--help) sed -n '2,22p' "${BASH_SOURCE[0]}"; exit 0 ;; + -*) die "unknown argument: $1" ;; + *) POSITIONAL+=("$1"); shift ;; + esac +done + +[ "${#POSITIONAL[@]}" -gt 0 ] || die "need an action: list, check or reset (see --help)" + +ACTION="${POSITIONAL[0]}" +NAME="${POSITIONAL[1]:-}" +PASSWORD="${POSITIONAL[2]:-}" + +case "$ACTION" in + list) ;; + check|reset) + [ -n "$NAME" ] || die "${ACTION}: need a member name" + [ -n "$PASSWORD" ] || die "${ACTION}: need a password" + ;; + *) die "unknown action: ${ACTION} (expected list, check or reset)" ;; +esac + +# The settings file to read, as the container sees it. Empty means "whichever +# forum is live", which is the common case and needs no explanation in the log. +SETTINGS='/var/www/html/Settings.php' + +if [ -n "$ENGINE" ]; then + SMF_TYPE=$(engine_smf_type "$ENGINE") || die "unknown engine: $ENGINE" + SAVED="$DOCKER_DIR/settings/Settings.${SMF_TYPE}.php" + + [ -f "$SAVED" ] || die "no saved settings for ${SMF_TYPE}; install it first with install-forum.sh --engine ${SMF_TYPE}" + + SETTINGS="/var/www/html/.docker/settings/Settings.${SMF_TYPE}.php" +fi + +cd "$BOARD_DIR" + +# The password goes through the environment rather than the argument list: +# arguments are visible to anything that can read the process table, and a +# password typed at a shell is quite enough exposure already. +docker compose exec -T \ + -e SMF_USER_ACTION="$ACTION" \ + -e SMF_USER_NAME="$NAME" \ + -e SMF_USER_PASSWORD="$PASSWORD" \ + -e SMF_USER_SETTINGS="$SETTINGS" \ + web php <<-'PHP' + <?php + + /* + * Runs inside the web container against the installed forum. Kept to the + * constants Config::load() and Db::load() actually read, because anything + * more would be pretending this is a request. + */ + + define('SMF', 1); + define('SMF_SETTINGS_FILE', getenv('SMF_USER_SETTINGS')); + define('SMF_SETTINGS_BACKUP_FILE', str_replace('Settings.php', 'Settings_bak.php', SMF_SETTINGS_FILE)); + + // Config::getSettingsDefs() reads both of these while working out what a + // Settings.php should contain. Taken from index.php rather than written out + // here, so this cannot disagree with the version it is running against. + $index = (string) file_get_contents('/var/www/html/index.php'); + + preg_match("~define\('SMF_VERSION', '([^']+)'\);~", $index, $version); + preg_match("~define\('SMF_SOFTWARE_YEAR', '(\d{4})'\);~", $index, $year); + + define('SMF_VERSION', $version[1] ?? '3.0'); + define('SMF_SOFTWARE_YEAR', $year[1] ?? date('Y')); + + define('SMF_FULL_VERSION', 'SMF ' . SMF_VERSION); + define('SMF_USER_AGENT', 'SMF dev tools'); + define('TIME_START', microtime(true)); + + // DatabaseApi::getClass() names the engine with these when it cannot find + // one, so they have to exist before the connection is made. + define('POSTGRE_TITLE', 'PostgreSQL'); + define('MYSQL_TITLE', 'MySQL'); + + require '/var/www/html/vendor/autoload.php'; + + SMF\Config::load(); + SMF\Db\DatabaseApi::load(); + SMF\Config::reloadModSettings(); + + $db = SMF\Db\DatabaseApi::$db; + $action = (string) getenv('SMF_USER_ACTION'); + $name = (string) getenv('SMF_USER_NAME'); + $password = (string) getenv('SMF_USER_PASSWORD'); + + fwrite(STDERR, '[smf-dev] ' . SMF\Config::$db_type . ' forum at ' . SMF\Config::$boardurl . "\n"); + + if ($action === 'list') { + $request = $db->query( + 'SELECT id_member, member_name, real_name, email_address, id_group, is_activated + FROM {db_prefix}members + ORDER BY id_member', + [], + ); + + printf("%-5s %-20s %-28s %-7s %s\n", 'id', 'member_name', 'email', 'group', 'activated'); + + while ($row = $db->fetch_assoc($request)) { + printf( + "%-5d %-20s %-28s %-7d %s\n", + $row['id_member'], + $row['member_name'], + $row['email_address'], + $row['id_group'], + // 1 is the only value that can log in; the rest are awaiting + // activation, awaiting approval, banned or deleted. + $row['is_activated'] == 1 ? 'yes' : 'no (' . $row['is_activated'] . ')', + ); + } + + $db->free_result($request); + + exit(0); + } + + $request = $db->query( + 'SELECT id_member, member_name, passwd, is_activated + FROM {db_prefix}members + WHERE member_name = {string:name} OR email_address = {string:name} + LIMIT 1', + [ + 'name' => $name, + ], + ); + + $member = $db->fetch_assoc($request); + $db->free_result($request); + + if (!is_array($member)) { + fwrite(STDERR, 'error: no member called "' . $name . '" (try: user.sh list)' . "\n"); + + exit(1); + } + + if ($action === 'check') { + $ok = SMF\Security::hashVerifyPassword($password, $member['passwd']); + + echo $member['member_name'], ': ', $ok ? 'password is correct' : 'password is WRONG', "\n"; + + // Being right about the password is not the same as being able to log + // in, and the difference is worth saying out loud before someone spends + // an afternoon on it. + if ($ok && $member['is_activated'] != 1) { + echo ' note: the account is not active (is_activated = ', $member['is_activated'], '), so it cannot log in', "\n"; + } + + exit($ok ? 0 : 1); + } + + $db->query( + 'UPDATE {db_prefix}members + SET passwd = {string:passwd}, passwd_flood = {string:empty} + WHERE id_member = {int:id}', + [ + 'passwd' => SMF\Security::hashPassword($password), + // Cleared as well: SMF locks an account out for a while after + // enough wrong guesses, and resetting the password while leaving + // the lockout in place looks exactly like the password not working. + 'empty' => '', + 'id' => (int) $member['id_member'], + ], + ); + + echo $member['member_name'], ': password changed', "\n"; + PHP From 218d95f2f7c2ce10e194f027ac6344bdc0893ca4 Mon Sep 17 00:00:00 2001 From: albertlast <mathiaspapealbert@hotmail.com> Date: Sun, 2 Aug 2026 21:14:08 +0200 Subject: [PATCH 26/26] Points the credentials note at user.sh The tests skip rather than fail when they cannot sign in, which says what is wrong but not what to do about it. user.sh answers both halves: check says whether the password the suite is using is the right one, and reset puts a forum installed some other way back on the credentials it expects. Signed-off-by: albertlast <mathiaspapealbert@hotmail.com> --- .docker/README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.docker/README.md b/.docker/README.md index 2f41761c8ff..df3e6170317 100644 --- a/.docker/README.md +++ b/.docker/README.md @@ -170,7 +170,9 @@ SMF_ADMIN_USER=admin SMF_ADMIN_PASS='…' .docker/test.sh Getting that wrong makes those tests **skip**, with a message saying so, rather than fail — a password the suite does not know is a misconfigured forum, not a -regression. +regression. `user.sh check admin '…'` settles which it is, and +`user.sh reset admin password` puts a forum installed some other way back on the +credentials the suite expects. ## Writing a test