diff --git a/.docker/README.md b/.docker/README.md index 2ba2f277fb..eaa2315f4d 100644 --- a/.docker/README.md +++ b/.docker/README.md @@ -53,6 +53,115 @@ 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. + +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 + 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. + +## 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. + +## 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 engine and copies `other/install.php` to the web root, so http://localhost:8080 redirects into the installer. @@ -102,8 +211,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 @@ -170,4 +279,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/install-forum.sh b/.docker/install-forum.sh new file mode 100755 index 0000000000..8bc5570027 --- /dev/null +++ b/.docker/install-forum.sh @@ -0,0 +1,205 @@ +#!/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" + ) + + # 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 + + 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" + + # 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 + 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 0000000000..5c13d41008 --- /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 100755 index 0000000000..00f2de5a40 --- /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/test.sh b/.docker/test.sh new file mode 100755 index 0000000000..7ea104629e --- /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/.docker/use-engine.sh b/.docker/use-engine.sh new file mode 100755 index 0000000000..5fd2b7a96b --- /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/.docker/user.sh b/.docker/user.sh new file mode 100755 index 0000000000..eebf4d1a8e --- /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' + 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 diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml new file mode 100644 index 0000000000..398c9f9ae1 --- /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 6b46b9c3a5..23f4dc34e8 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 # ######################## @@ -94,3 +98,7 @@ vendor/ .phplint-cache .phplint.cache composer.phar + +# PHPUnit +.phpunit.cache/ +.phpunit.result.cache diff --git a/AGENTS.md b/AGENTS.md index acec8dea35..f7b8d1d8ce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -110,12 +110,122 @@ 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. +### Tests -So verify by running the forum. The repository ships a Docker environment, documented -in full in `.docker/README.md`: +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 +``` + +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. + 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 + 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. + +### 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)`, +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 +running the forum. The repository ships a Docker environment, documented in full in +`.docker/README.md`: ```bash docker compose up -d --build @@ -146,10 +256,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 diff --git a/Languages/en_US/Maintenance.php b/Languages/en_US/Maintenance.php index 3f3f4ee447..21dceeb9f5 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/Maintenance.php b/Sources/Maintenance/Maintenance.php index 8809ee6f5f..8c47056f45 100644 --- a/Sources/Maintenance/Maintenance.php +++ b/Sources/Maintenance/Maintenance.php @@ -808,6 +808,56 @@ 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 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 ' . $stopped->getId() + . ' of ' . \count($steps) + . ' (' . $stopped->getName() . ")\n", + ); + } + } + // We usually dump our templates out. if (!$fallthrough) { // Send character set. @@ -920,4 +970,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/Install.php b/Sources/Maintenance/Tools/Install.php index 7cc6ee38b4..4e543bc495 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); @@ -1182,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'); @@ -1424,7 +1454,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); } /** diff --git a/Sources/Maintenance/Tools/ToolsBase.php b/Sources/Maintenance/Tools/ToolsBase.php index e2b14b1aea..61ae5bd414 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; diff --git a/Sources/Url.php b/Sources/Url.php index 9cba0ab872..30e4b8f309 100644 --- a/Sources/Url.php +++ b/Sources/Url.php @@ -722,6 +722,9 @@ 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. */ diff --git a/composer.json b/composer.json index c08fcb3848..8c9352d4aa 100644 --- a/composer.json +++ b/composer.json @@ -16,9 +16,13 @@ "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": { + "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/composer.lock b/composer.lock index 0f762a87ed..c092fa442b 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": "5741c6f1fd0055c11f7cc35751f40b2b", + "content-hash": "50c2bac5d85b523f36379e484a555b9d", "packages": [ { "name": "bjeavons/zxcvbn-php", @@ -1025,6 +1025,123 @@ ], "time": "2026-07-30T15:46:02+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", @@ -1109,705 +1226,2335 @@ "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.4", "source": { "type": "git", - "url": "https://github.com/php-fig/log.git", - "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "048a5c12bdb4580f4767ce2761793a16b170fbe4" }, "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/048a5c12bdb4580f4767ce2761793a16b170fbe4", + "reference": "048a5c12bdb4580f4767ce2761793a16b170fbe4", "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.4" }, - "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-30T17:01:07+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": "phpunit/php-invoker", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88", + "reference": "42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^13.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.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": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "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://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": "2026-02-06T04:34:47+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/a47af19f93f76aa3368303d752aa5272ca3299f4", + "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4", + "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", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "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://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": "2026-02-06T04:36:37+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "9.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "a0e12065831f6ab0d83120dc61513eb8d9a966f6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/a0e12065831f6ab0d83120dc61513eb8d9a966f6", + "reference": "a0e12065831f6ab0d83120dc61513eb8d9a966f6", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "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", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "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://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": "2026-02-06T04:37:53+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "13.2.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "5d2afe181339a56348ef9a80fa7eb806b7eae508" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/5d2afe181339a56348ef9a80fa7eb806b7eae508", + "reference": "5d2afe181339a56348ef9a80fa7eb806b7eae508", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-filter": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "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.2.3", + "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.3.0", + "sebastian/diff": "^9.0", + "sebastian/environment": "^9.3.2", + "sebastian/exporter": "^8.1.1", + "sebastian/file-filter": "^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.2-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "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.2.6" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-07-28T14:00:09+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/" + }, + { + "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, Promise-based cache interface for ReactPHP", + "keywords": [ + "cache", + "caching", + "promise", + "reactphp" + ], + "support": { + "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" + } + ], + "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.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "eeb759ad3146b7096fb59c3195d39e071cd409e3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/eeb759ad3146b7096fb59c3195d39e071cd409e3", + "reference": "eeb759ad3146b7096fb59c3195d39e071cd409e3", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.2.6" + }, + "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.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/cli-parser", + "type": "tidelift" + } + ], + "time": "2026-08-01T04:27:14+00:00" + }, + { + "name": "sebastian/comparator", + "version": "8.3.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "c025fc7604afab3f195fab7cdaf72327331af241" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/c025fc7604afab3f195fab7cdaf72327331af241", + "reference": "c025fc7604afab3f195fab7cdaf72327331af241", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.4", + "sebastian/diff": "^9.0", + "sebastian/exporter": "^8.1.0" + }, + "require-dev": { + "phpunit/phpunit": "^13.2" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "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": "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.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/comparator", + "type": "tidelift" + } + ], + "time": "2026-06-05T03:06:45+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": "9.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "a3fb6a298a265ff487a91bbea46e03cd01dbb226" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/a3fb6a298a265ff487a91bbea46e03cd01dbb226", + "reference": "a3fb6a298a265ff487a91bbea46e03cd01dbb226", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.2", + "symfony/process": "^7.4.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" + }, + { + "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/9.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/diff", + "type": "tidelift" + } + ], + "time": "2026-06-05T03:04:51+00:00" + }, { - "name": "react/child-process", - "version": "v0.6.7", + "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/file-filter", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/file-filter.git", + "reference": "33a26f394330f6faa7684bb9cc73afb7727aae93" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/file-filter/zipball/33a26f394330f6faa7684bb9cc73afb7727aae93", + "reference": "33a26f394330f6faa7684bb9cc73afb7727aae93", + "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 filtering files", + "homepage": "https://github.com/sebastianbergmann/file-filter", + "support": { + "issues": "https://github.com/sebastianbergmann/file-filter/issues", + "security": "https://github.com/sebastianbergmann/file-filter/security/policy", + "source": "https://github.com/sebastianbergmann/file-filter/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/file-filter", + "type": "tidelift" + } + ], + "time": "2026-04-22T07:20:04+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/reactphp/child-process.git", - "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3" + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "ba68ba79da690cf7eddefd3ce5b78b20b9ba9945" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/child-process/zipball/970f0e71945556422ee4570ccbabaedc3cf04ad3", - "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/ba68ba79da690cf7eddefd3ce5b78b20b9ba9945", + "reference": "ba68ba79da690cf7eddefd3ce5b78b20b9ba9945", "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", + "sebastian/object-reflector": "^6.0", + "sebastian/recursion-context": "^8.0" }, "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-dom": "*", + "phpunit/phpunit": "^13.1.13" }, "type": "library", - "autoload": { - "psr-4": { - "React\\ChildProcess\\": "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" } ], - "description": "Event-driven library for executing child processes with ReactPHP.", + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", "keywords": [ - "event-driven", - "process", - "reactphp" + "global state" ], "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/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://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/global-state", + "type": "tidelift" } ], - "time": "2025-12-23T15:25:20+00:00" + "time": "2026-06-01T15:11:33+00:00" }, { - "name": "react/dns", - "version": "v1.14.0", + "name": "sebastian/lines-of-code", + "version": "5.0.2", "source": { "type": "git", - "url": "https://github.com/reactphp/dns.git", - "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3" + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d1b6f8fce682505dbd048977f1abedf1b8ad3ff8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/dns/zipball/7562c05391f42701c1fccf189c8225fece1cd7c3", - "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d1b6f8fce682505dbd048977f1abedf1b8ad3ff8", + "reference": "d1b6f8fce682505dbd048977f1abedf1b8ad3ff8", "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" + "nikic/php-parser": "^5.8.0", + "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.2.4" }, "type": "library", - "autoload": { - "psr-4": { - "React\\Dns\\": "src/" + "extra": { + "branch-alias": { + "dev-main": "5.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", - "keywords": [ - "async", - "dns", - "dns-resolver", - "reactphp" - ], + "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/reactphp/dns/issues", - "source": "https://github.com/reactphp/dns/tree/v1.14.0" + "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://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/lines-of-code", + "type": "tidelift" } ], - "time": "2025-11-18T19:34:28+00:00" + "time": "2026-07-09T08:42:34+00:00" }, { - "name": "react/event-loop", - "version": "v1.6.0", + "name": "sebastian/object-enumerator", + "version": "8.0.0", "source": { "type": "git", - "url": "https://github.com/reactphp/event-loop.git", - "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a" + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "b39ab125fd9a7434b0ecbc4202eebce11a98cfc5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/event-loop/zipball/ba276bda6083df7e0050fd9b33f66ad7a4ac747a", - "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/b39ab125fd9a7434b0ecbc4202eebce11a98cfc5", + "reference": "b39ab125fd9a7434b0ecbc4202eebce11a98cfc5", "shasum": "" }, "require": { - "php": ">=5.3.0" + "php": ">=8.4", + "sebastian/object-reflector": "^6.0", + "sebastian/recursion-context": "^8.0" }, "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": "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": "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" } ], - "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", - "keywords": [ - "asynchronous", - "event-loop" - ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", "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/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://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/object-enumerator", + "type": "tidelift" } ], - "time": "2025-11-17T20:46:25+00:00" + "time": "2026-02-06T04:46:36+00:00" }, { - "name": "react/promise", - "version": "v3.3.0", + "name": "sebastian/object-reflector", + "version": "6.0.0", "source": { "type": "git", - "url": "https://github.com/reactphp/promise.git", - "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "3ca042c2c60b0eab094f8a1b6a7093f4d4c72200" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", - "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/3ca042c2c60b0eab094f8a1b6a7093f4d4c72200", + "reference": "3ca042c2c60b0eab094f8a1b6a7093f4d4c72200", "shasum": "" }, "require": { - "php": ">=7.1.0" + "php": ">=8.4" }, "require-dev": { - "phpstan/phpstan": "1.12.28 || 1.4.10", - "phpunit/phpunit": "^9.6 || ^7.5" + "phpunit/phpunit": "^13.0" }, "type": "library", - "autoload": { - "files": [ - "src/functions_include.php" - ], - "psr-4": { - "React\\Promise\\": "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": "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/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" } ], - "description": "A lightweight implementation of CommonJS Promises/A for PHP", - "keywords": [ - "promise", - "promises" - ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", "support": { - "issues": "https://github.com/reactphp/promise/issues", - "source": "https://github.com/reactphp/promise/tree/v3.3.0" + "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://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/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.1", "source": { "type": "git", - "url": "https://github.com/reactphp/socket.git", - "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08" + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "32dba72f2b4642d6a93db22d6c0a9280ff2e3ca0" }, "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/32dba72f2b4642d6a93db22d6c0a9280ff2e3ca0", + "reference": "32dba72f2b4642d6a93db22d6c0a9280ff2e3ca0", "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.2.6" }, "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.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/recursion-context", + "type": "tidelift" } ], - "time": "2025-11-19T20:47:34+00:00" + "time": "2026-08-03T05:58:12+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": "9.0.0", + "name": "sebastian/version", + "version": "7.0.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "a3fb6a298a265ff487a91bbea46e03cd01dbb226" + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/a3fb6a298a265ff487a91bbea46e03cd01dbb226", - "reference": "a3fb6a298a265ff487a91bbea46e03cd01dbb226", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/ad37a5552c8e2b88572249fdc19b6da7792e021b", + "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b", "shasum": "" }, "require": { "php": ">=8.4" }, - "require-dev": { - "phpunit/phpunit": "^13.2", - "symfony/process": "^7.4.13" - }, "type": "library", "extra": { "branch-alias": { - "dev-main": "9.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -1822,25 +3569,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/9.0.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": [ { @@ -1856,11 +3594,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-06-05T03:04:51+00:00" + "time": "2026-02-06T04:52:52+00:00" }, { "name": "simplemachines/build-tools", @@ -1888,6 +3626,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.43", @@ -3570,6 +5360,56 @@ } ], "time": "2026-07-20T15:18:49+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 0000000000..720240fd3c --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,28 @@ + + + + + tests/Unit + + + + tests/Integration + + + + + Sources + + + diff --git a/tests/Integration/HarnessTest.php b/tests/Integration/HarnessTest.php new file mode 100644 index 0000000000..ac7ceb8ffc --- /dev/null +++ b/tests/Integration/HarnessTest.php @@ -0,0 +1,140 @@ +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 0000000000..4505b2675f --- /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 0000000000..3475c80dd3 --- /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 0000000000..93a8312a96 --- /dev/null +++ b/tests/Integration/ModSettingsTest.php @@ -0,0 +1,123 @@ + '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 0000000000..145975992b --- /dev/null +++ b/tests/Integration/SchemaTest.php @@ -0,0 +1,104 @@ +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 0000000000..2844a3b9e7 --- /dev/null +++ b/tests/Integration/index.php @@ -0,0 +1,8 @@ +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 + { + // 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(AgreementAccept::class, AgreementAccept::load()); + $this->assertInstanceOf(UnreadReplies::class, UnreadReplies::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 0000000000..8bdf1a515a --- /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/IPTest.php b/tests/Unit/IPTest.php new file mode 100644 index 0000000000..0efe911f33 --- /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 0000000000..f2a4437642 --- /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 0000000000..8e5d3875fe --- /dev/null +++ b/tests/Unit/SapiTest.php @@ -0,0 +1,77 @@ +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()); + } + + 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 testMemoryReturnBytes(string $val, int $expected): void + { + $this->assertSame($expected, Sapi::memoryReturnBytes($val)); + } + + /*********************** + * Public static methods + ***********************/ + + /** + * @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], + 'plain byte count' => ['128', 128], + 'zero' => ['0', 0], + 'empty' => ['', 0], + ]; + } +} diff --git a/tests/Unit/SecurityTest.php b/tests/Unit/SecurityTest.php new file mode 100644 index 0000000000..a1b8d401c0 --- /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 0000000000..f9d2eddda7 --- /dev/null +++ b/tests/Unit/TimeIntervalTest.php @@ -0,0 +1,73 @@ +assertSame('P1Y2M3DT4H5M6S', (string) new TimeInterval('P1Y2M3DT4H5M6S')); + } + + public function testTimeOnlyDurationsKeepTheirTimeDesignator(): void + { + // The point here is the 'T': without it, the 'M' would read as months + // rather than minutes. + $this->assertSame('PT30M', (string) new TimeInterval('PT30M')); + } + + public function testARedundantZeroDayIsNotWrittenBackOut(): void + { + // This used to answer 'P0DT30M', because the class populated days for a + // duration naming no years or months and then always wrote them. Since + // it went back to \DateInterval's constructor, days stays false for + // anything built from a string and the zero component is not invented. + $this->assertSame('PT30M', (string) new TimeInterval('P0DT30M')); + } + + 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 + { + // Note the singular 'year' against the plural everything else; the + // units are pluralised one at a time based on their own value. + $this->assertSame( + '1 year 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 0000000000..913cc10147 --- /dev/null +++ b/tests/Unit/UrlTest.php @@ -0,0 +1,128 @@ +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')); + } + + 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'])); + } + + 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')] + 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 new file mode 100644 index 0000000000..ffc139b707 --- /dev/null +++ b/tests/Unit/UtilsTest.php @@ -0,0 +1,184 @@ +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'); + } + + 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 0000000000..b09b7e65c7 --- /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/Unit/index.php b/tests/Unit/index.php new file mode 100644 index 0000000000..2844a3b9e7 --- /dev/null +++ b/tests/Unit/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 + * 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'; diff --git a/tests/index.php b/tests/index.php new file mode 100644 index 0000000000..2844a3b9e7 --- /dev/null +++ b/tests/index.php @@ -0,0 +1,8 @@ +