diff --git a/.docker/README.md b/.docker/README.md new file mode 100644 index 00000000000..df3e6170317 --- /dev/null +++ b/.docker/README.md @@ -0,0 +1,457 @@ +# SMF development environment + +A throwaway, reproducible local stack for working on SMF 3.0. Nothing here is +part of the shipped forum — it lives in `.docker/` precisely so the CI checks +(`check-smf-index.php`, `check-smf-license.php`) skip it. + +Both database engines SMF supports are in the stack. **MySQL is the default.** + +## Requirements + +Docker Desktop (Linux containers). Nothing else — no local PHP, Composer, MySQL +or PostgreSQL install is needed. + +## Start + +```sh +docker compose up -d --build +``` + +First boot takes a few minutes: it builds the PHP image and runs +`composer install` into `vendor/`. Watch it with `docker compose logs -f web` +and wait for the `[smf-dev] ready` line. + +| Service | URL / address | Notes | +| ---------- | ----------------------- | --------------------------------------- | +| Forum | http://localhost:8080 | The forum itself | +| Mailpit | http://localhost:8025 | Every mail the forum sends lands here | +| Adminer | http://localhost:8081 | Database browser, pre-pointed at MySQL | +| MySQL | `localhost:3307` | For a client on the host | +| PostgreSQL | `localhost:5433` | For a client on the host | + +Credentials are `smf` / `smf` / database `smf` on both engines. + +## Choosing the engine + +Both database services always start. `SMF_DB_TYPE` decides which one the forum +is pointed at, and it defaults to `mysql`: + +```sh +# In .env, or inline: +SMF_DB_TYPE=postgresql docker compose up -d +``` + +This only affects the `Settings.php` the entrypoint *generates*. Once the forum +is installed, `Settings.php` is what counts and changing the variable does +nothing — the entrypoint says so in the log rather than leaving you guessing. To +move to the other engine, delete `Settings.php` and `Settings_bak.php`, then +restart `web` and reinstall. + +Because both engines run side by side with separate volumes, you can install on +one, switch, install on the other, and switch back: each database keeps its own +forum. + +## Installing the forum + +```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. + +Some of the tests sign in, so they need to know the administrator. They default +to what `install-forum.sh` creates (`admin` / `password`); if your forum has +different credentials, export them: + +```sh +SMF_ADMIN_USER=admin SMF_ADMIN_PASS='…' .docker/test.sh +``` + +Getting that wrong makes those tests **skip**, with a message saying so, rather +than fail — a password the suite does not know is a misconfigured forum, not a +regression. `user.sh check admin '…'` settles which it is, and +`user.sh reset admin password` puts a forum installed some other way back on the +credentials the suite expects. + +## Writing a test + +### Which suite + +Three of them, and picking the wrong one is the usual reason a test is harder to +write than it should be: + +| Suite | Has | Use it for | +| ------------------------ | -------------------------------------- | ----------------------------- | +| `tests/Unit` | nothing — no database, no request | pure functions, value objects | +| `tests/Integration` | `Db::$db`, `$modSettings`, `User::$me` | anything needing real data | +| `tests/Integration/Http` | all of that, plus a real request | proving a *page* works | + +Work down the list and stop at the first that can hold the test. An HTTP test +costs about a second and cannot be rolled back; a unit test costs nothing. The +limits of the unit suite are spelled out in `AGENTS.md`. + +Reach for HTTP only when the thing worth proving is in the parts nothing else +touches: the session, the cookies, the security token, the theme and the +templates. `User::setMe()` skips every one of them, which is exactly why the +plain integration tests are cheap. + +### The shape of an HTTP test + +Four beats: fetch a page, submit a form on it, assert on what came back, assert +nothing was logged. + +```php +#[CoversNothing] +class ProfileTest extends HttpTestCase +{ + public function testAMemberCanChangeTheirSignature(): void + { + $this->signInAsAdmin(); + + $form = $this->fetch('?action=profile;area=forumprofile'); + + $response = $this->submitForm($form, [ + 'signature' => 'Set by the integration suite.', + 'save' => 'Change profile', // the button + ], '//form[contains(@action, "area=forumprofile")]'); + + $this->assertLessThan(400, $response->status, $response->errorText()); + $this->assertNoErrorsLogged('saving a signature logged something.' . "\n"); + } +} +``` + +`#[CoversNothing]` is not optional. These cross dozens of classes, so naming one +would be untrue, and `failOnRisky` wants an attribute either way. + +Four things that are easy to get wrong: + +- **Submit through `submitForm()`, not `HttpClient::submit()`.** Only the former + waits out flood control. `Security::spamProtection()` gives a moderator two + seconds between posts, per IP, and the tests all arrive from the same one far + faster than a person would; without the wait you get a suite that fails about + one run in five for no reproducible reason. +- **Name the button you are pressing.** `formFields()` leaves every button out on + purpose, because the posting form carries both `preview` and `post` and sending + the pair means preview quietly wins — no post, and a perfectly good 200 to show + for it. +- **Clean up whatever you write.** There is no transaction here; see + `HttpTestCase::usesTransaction()` for why one would not help. `PostingTest` + deletes through `Topic::remove()` rather than by hand, so the board and member + counters go back as well. +- **`assertNoErrorsLogged()` is the point of the test**, not a formality. A page + can return exactly the right HTML while logging an undefined index, and that is + the failure mode this whole suite exists to catch. + +One thing to rule out before believing a failure: if `install.php` is still in +the board root, SMF puts a "MAJOR SECURITY RISK" box on every page it shows an +administrator. That is an `errorbox`, so it fails `assertLooksLikeAForumPage()` +and turns up in `errorText()` in front of whatever the test was actually looking +at. `install-forum.sh` removes the file once it is done; a forum installed +through the browser needs it deleting by hand. + +### Who the request is + +The identity of an HTTP request is the cookie jar and nothing else. There are two +states out of the box: a guest, which is what `setUp()` leaves you, and the +administrator, through `signInAsAdmin()`. + +**`actingAs()` does not work here.** It is inherited from `IntegrationTestCase` +and it repoints `User::$me` in the PHPUnit process — but the request is handled +by Apache in a different process, which knows only the cookie. Calling it in an +HTTP test changes nothing about the request and leaves the assertions describing +a guest, confidently. + +So: + +- **Two users at once** means two `HttpClient` instances. Each opens its own + cookie jar, so they are independent browsers — which is how to test one member + sending another a PM. +- **Back to being a guest** is `$this->http->forgetCookies()`, then + `$this->http->get('')` to pick up a fresh session. +- **A member who is not the administrator** has to be made first, through + `Register2::registerMember()` with `interface => 'admin'` (which needs + `actingAs($this->adminId())` first, as it checks `moderate_forum`). That member + outlives the test, so delete it in `tearDown()`. + +### Finding the endpoint and the field names + +Endpoints are looked up; field names are not. + +`Forum::$actions` in `Sources/Forum.php` is the authoritative list of every +`?action=` the forum answers and the class behind it. Sub-actions — the `;area=` +and `;sa=` parts — are a `$subactions` property on that class. So +`?action=profile;area=forumprofile` resolves as `$actions['profile']` → +`Actions\Profile\Main` → its `$subactions`. That is quicker and more reliable +than reading templates. + +Field names you are deliberately not meant to know. `HttpClient::submit()` +scrapes every input, textarea and select out of the form it was handed and sends +them back, the way a browser does. That is what carries the session check and the +security token, both named unpredictably per session and neither hardcodable. All +a test supplies is the few values it is choosing, plus the button. + +When you do need to see them, ask the page rather than the template: + +```sh +docker compose exec web php -r ' + require "tests/bootstrap.php"; + $c = new SMF\Tests\Support\HttpClient(); + $c->get(""); + $p = $c->get("?action=login"); + print_r($p->formFields("//form[contains(@action, \"login2\")]"));' +``` + +``` +Array +( + [user] => + [passwrd] => + [d0004e1655] => b2f5189a9b3014cadee7bcb0b8d697f2 + [b8ae8fd32d] => 8f6026ed9a1b91bf6fec315801a2a93c +) +``` + +Two named fields, which are the ones a test writes, and two whose names are +different for every session — the session check and the security token, and +running the command twice gives two different pairs. That is what +`submit()` is for, and why a test that builds its own POST body by hand gets a +403 it cannot fix. + +The paths are relative because the container's working directory is +`/var/www/html` already. Spelling them absolutely also works, but not from Git +Bash on Windows, which rewrites anything that looks like a Unix path before +Docker sees it. + +Note the throwaway `get("")` before the form is fetched. The very first request +of a new session regenerates it, so a token minted on the first page a visitor +ever sees is bound to a session that no longer exists by the time it comes back. +The symptom is a 403 about the token, when the token was never the problem. + +### Installing in a browser instead + +On first boot the entrypoint writes a `Settings.php` pre-filled for the chosen +engine and copies `other/install.php` to the web root, so +http://localhost:8080 redirects into the installer. + +The installer does **not** read its form defaults from `Settings.php` — it uses +the hardcoded defaults in the database API class. On the *Database Server +Settings* step, enter: + +| Field | MySQL | PostgreSQL | +| ------------- | ------- | ------------ | +| Database type | `MySQL` | `PostgreSQL` | +| Server | `mysql` | `postgres` | +| Port | `3306` | `5432` | +| Username | `smf` | `smf` | +| Password | `smf` | `smf` | +| Database name | `smf` | `smf` | + +The internal ports are correct here: `3307` and `5433` are only how the *host* +reaches the databases from outside Docker. Containers talk to each other on the +compose network. + +When the installer finishes, delete `install.php` from the repo root — while it +exists, `Settings.php` redirects every request back into the installer. + +## Everyday use + +```sh +docker compose logs -f web # apache + php errors, live +docker compose logs -f postgres # every failing query, with its SQL +docker compose exec web bash # shell in the web container + +docker compose exec mysql mysql -usmf -psmf smf # mysql client +docker compose exec postgres psql -U smf # psql + +docker compose exec web composer install +docker compose exec web composer lint + +docker compose up -d --build web # after changing anything in .docker/php/ +docker compose down # stop, keep both databases +docker compose down -v # stop and destroy both databases +``` + +`php.ini`, the vhost and the entrypoint are copied into the image at build time, +not bind-mounted, so a plain `restart` will not pick up edits to them. Rebuild. + +The repository is bind-mounted at `/var/www/html`, so edits on the host are +live on the next request. Opcache is on but revalidates every request, so you +never need to restart for a PHP change. + +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 + +The `postgres` log is the best tool in the stack for tracking down a broken +query. PostgreSQL logs every statement that errors together with the SQL that +caused it, always and without any configuration: + +``` +2026-01-01 12:00:00.000 UTC [98] ERROR: relation "nope" does not exist at character 15 +2026-01-01 12:00:00.000 UTC [98] STATEMENT: select * from nope; +``` + +Nothing is written to a file inside the container, so the compose log above is +where to look. Add `--since 5m` to it to skip past the startup noise. + +MySQL has no equivalent: it logs server errors only, never the client statement +that failed, so a query SMF gets wrong leaves no trace in its log. Since MySQL +is the default engine, a suspected SQL problem is worth reproducing against +PostgreSQL — install on it once and you can switch back and forth, because each +database keeps its own forum. + +Only failing statements are logged. Successful ones, timings and connections +are not, so this shows you the queries that break, not the ones that merely +return the wrong thing. + +## Configuration + +`compose.yaml` works with no `.env` file. To change ports, versions, the engine +or credentials, copy `.docker/env.example` to `.env` in the repository root. + +The `postgres` service also answers to the hostname `db`, which is what +`Settings.php` files generated before MySQL was added point at. + +## What is in the image + +PHP 8.4 on Apache, with everything `other/requirements.md` lists: + +- Required: `mbstring`, `fileinfo`, and both `mysqli` and `pgsql` (SMF checks + for `pg_connect`), so either engine can be chosen at install time. +- Recommended: `gd`, `intl`, `curl`, `exif`, `ftp`, `xsl`, and `zip`. +- Both database command line clients, for the `docker compose exec` recipes + above and for the entrypoint's readiness check. +- `mail()` is routed through msmtp into Mailpit, so no mail can escape the + machine. + +Engine settings SMF asks for are pinned at server level rather than left to the +image defaults: + +- PostgreSQL: `standard_conforming_strings = on`, as `requirements.md` requires. +- MySQL: `utf8mb4` and InnoDB, matching SMF's own table DDL. The collation is + deliberately left at the charset default, because SMF sets `CHARSET` without + `COLLATE`; forcing a different one here would diverge from the tables it + creates. + +## Files + +``` +compose.yaml the stack +.docker/php/Dockerfile PHP + Apache image +.docker/php/php.ini dev php settings, per requirements.md +.docker/php/vhost.conf apache vhost +.docker/php/msmtprc mail() -> mailpit +.docker/php/entrypoint.sh composer install, Settings.php, permissions +.docker/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/env.example b/.docker/env.example new file mode 100644 index 00000000000..31736f2a186 --- /dev/null +++ b/.docker/env.example @@ -0,0 +1,31 @@ +# Copy to the repository root as `.env` to override any of the defaults. +# compose.yaml works without this file. + +# Which engine the forum runs on: mysql (default) or postgresql. +# Both database services start either way. This only decides what the generated +# Settings.php points at, so it has no effect once the forum is installed -- +# Settings.php wins from then on. +SMF_DB_TYPE=mysql + +# Host ports +WEB_PORT=8080 +ADMINER_PORT=8081 +MAILPIT_PORT=8025 +MYSQL_PORT=3307 +POSTGRES_PORT=5433 + +# Versions +PHP_VERSION=8.4 +MYSQL_VERSION=8.4 +POSTGRES_VERSION=17-alpine + +# Database credentials (dev only). Shared by both engines so that switching +# SMF_DB_TYPE needs no other change. +DB_NAME=smf +DB_USER=smf +DB_PASSWORD=smf +DB_ROOT_PASSWORD=smf + +# Which service Adminer pre-fills in its server field. Service name, not engine +# name: mysql or postgres. +ADMINER_SERVER=mysql diff --git a/.docker/install-forum.sh b/.docker/install-forum.sh new file mode 100755 index 00000000000..8bc55700270 --- /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 00000000000..5c13d410080 --- /dev/null +++ b/.docker/lib.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +# Shared settings and helpers for the .docker scripts. Sourced, never run. +# +# Host-side scripts (reset.sh, install-forum.sh, use-engine.sh) source this from +# wherever the caller happens to be standing; everything below resolves paths +# for itself rather than assuming a working directory. +# +# Everything defined here is consumed by the scripts that source this file, and +# a linter reading it on its own cannot see any of those uses -- hence the +# blanket disable below. Keep it on its own, with nothing after it that starts +# with the linter's name, or the following line gets parsed as a directive too. +# +# shellcheck disable=SC2034 + +# Git Bash on Windows rewrites anything that looks like a Unix path before +# handing it to a program, so a container-side path like /var/www/html/... is +# silently turned into C:/Program Files/Git/var/www/html/... and the command +# fails with "Could not open input file". These two switch that off. They mean +# nothing on Linux and macOS. +export MSYS_NO_PATHCONV=1 +export MSYS2_ARG_CONV_EXCL='*' + +# Repository root, regardless of where the caller was standing. +DOCKER_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +BOARD_DIR=$(cd -- "$DOCKER_DIR/.." && pwd) + +# Where use-engine.sh keeps each engine's Settings.php. Gitignored: these hold +# generated secrets and a machine-specific board URL. +SETTINGS_DIR="$DOCKER_DIR/settings" + +# ---------------------------------------------------------------- credentials +# These match compose.yaml's defaults. Override them in the environment if you +# changed them in .env. +DB_NAME="${DB_NAME:-smf}" +DB_USER="${DB_USER:-smf}" +DB_PASSWORD="${DB_PASSWORD:-smf}" +DB_ROOT_PASSWORD="${DB_ROOT_PASSWORD:-smf}" +DB_PREFIX="${DB_PREFIX:-smf_}" + +WEB_PORT="${WEB_PORT:-8080}" +SMF_BOARDURL="${SMF_BOARDURL:-http://localhost:${WEB_PORT}}" +SMF_MBNAME="${SMF_MBNAME:-SMF Dev}" + +# The administrator the installer creates. Dev-only values for a throwaway +# forum; never reuse them anywhere real. +SMF_ADMIN_USER="${SMF_ADMIN_USER:-admin}" +SMF_ADMIN_PASS="${SMF_ADMIN_PASS:-password}" +# example.com is reserved by RFC 2606, so this can never reach a real inbox. +# SMF's validator rejects dotless domains, so 'admin@localhost' is not an option. +SMF_ADMIN_EMAIL="${SMF_ADMIN_EMAIL:-admin@example.com}" + +# --------------------------------------------------------------------- output +log() { printf '[smf-dev] %s\n' "$*"; } +warn() { printf '[smf-dev] %s\n' "$*" >&2; } +die() { printf '[smf-dev] error: %s\n' "$*" >&2; exit 1; } + +# Engine name normalisation. Everything downstream uses either the SMF type +# ('mysql' / 'postgresql') or the compose service name ('mysql' / 'postgres'), +# and mixing them up is an easy way to waste an afternoon. +engine_smf_type() { + case "$1" in + mysql|mysqli|mariadb) echo 'mysql' ;; + postgres|postgresql|pgsql) echo 'postgresql' ;; + *) return 1 ;; + esac +} + +engine_service() { + case "$1" in + mysql|mysqli|mariadb) echo 'mysql' ;; + postgres|postgresql|pgsql) echo 'postgres' ;; + *) return 1 ;; + esac +} + +# Container-internal host and port for an engine. Not the host-side ports in +# compose.yaml: these are what Settings.php has to contain. +engine_server() { + case "$(engine_smf_type "$1")" in + mysql) echo "${SMF_MYSQL_SERVER:-mysql}" ;; + postgresql) echo "${SMF_POSTGRES_SERVER:-postgres}" ;; + *) return 1 ;; + esac +} + +engine_port() { + case "$(engine_smf_type "$1")" in + mysql) echo "${SMF_MYSQL_PORT:-3306}" ;; + postgresql) echo "${SMF_POSTGRES_PORT:-5432}" ;; + *) return 1 ;; + esac +} + +# Expands "both" into the engines to act on, in the order they run. Only one +# engine can be live at a time -- Settings.php pins $db_type and Db::load() +# early-returns once the connection exists -- so "both" is a sequential chain, +# never two connections. +engine_list() { + case "$1" in + both|all) echo 'mysql postgresql' ;; + *) engine_smf_type "$1" ;; + esac +} + +# The installed version for one engine, empty if the forum is not installed. +# Asks the database directly rather than trusting the presence of a file: +# Settings.php exists from the moment the entrypoint writes it, long before +# there is a forum behind it. +installed_version() { + local engine service + engine=$(engine_smf_type "$1") || return 1 + service=$(engine_service "$1") + + if [ "$engine" = 'mysql' ]; then + docker compose exec -T -e MYSQL_PWD="$DB_PASSWORD" "$service" \ + mysql -u"$DB_USER" -D "$DB_NAME" -N -B -e \ + "SELECT value FROM ${DB_PREFIX}settings WHERE variable = 'smfVersion';" 2>/dev/null + else + docker compose exec -T "$service" \ + psql -U "$DB_USER" -d "$DB_NAME" -tAX -c \ + "SELECT value FROM ${DB_PREFIX}settings WHERE variable = 'smfVersion';" 2>/dev/null + fi +} diff --git a/.docker/mysql/init/10-smf.sh b/.docker/mysql/init/10-smf.sh new file mode 100644 index 00000000000..6f1589bdaa5 --- /dev/null +++ b/.docker/mysql/init/10-smf.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# Runs once, on first initialisation of the mysql data volume. +set -eu + +# SMF creates its own tables as InnoDB/utf8mb4, but the database's own default is +# what anything created outside that path inherits. Pinning it means it cannot +# drift out from under the forum, the same reason the postgres side pins +# standard_conforming_strings. +# +# The collation is left to whatever utf8mb4 defaults to on this server, because +# that is what SMF's tables get: its DDL sets CHARSET but never COLLATE. +mysql --protocol=socket -uroot -p"$MYSQL_ROOT_PASSWORD" <<-EOSQL + ALTER DATABASE \`${MYSQL_DATABASE}\` CHARACTER SET utf8mb4; +EOSQL + +echo "[smf-dev] database ${MYSQL_DATABASE} initialised" diff --git a/.docker/php/Dockerfile b/.docker/php/Dockerfile new file mode 100644 index 00000000000..fcfb0ed41dd --- /dev/null +++ b/.docker/php/Dockerfile @@ -0,0 +1,48 @@ +# SMF development image: PHP + Apache with everything other/requirements.md asks for. +ARG PHP_VERSION=8.4 +FROM php:${PHP_VERSION}-apache + +COPY --from=mlocati/php-extension-installer:2 /usr/bin/install-php-extensions /usr/local/bin/ +COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer + +# Required by SMF: mbstring, fileinfo, pgsql (pg_connect), mysqli. +# Recommended by SMF: gd, intl, curl, exif, ftp, xsl. +RUN install-php-extensions \ + pgsql \ + pdo_pgsql \ + mysqli \ + mbstring \ + fileinfo \ + gd \ + intl \ + curl \ + exif \ + ftp \ + xsl \ + zip \ + opcache \ + && a2enmod rewrite headers expires \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + git \ + unzip \ + msmtp \ + msmtp-mta \ + postgresql-client \ + default-mysql-client \ + && rm -rf /var/lib/apt/lists/* + +# Route mail() at Mailpit so outgoing forum mail is captured, never sent. +COPY .docker/php/msmtprc /etc/msmtprc +RUN chmod 0644 /etc/msmtprc + +COPY .docker/php/php.ini /usr/local/etc/php/conf.d/zz-smf.ini +COPY .docker/php/vhost.conf /etc/apache2/sites-available/000-default.conf +COPY .docker/php/entrypoint.sh /usr/local/bin/smf-entrypoint + +RUN chmod +x /usr/local/bin/smf-entrypoint + +WORKDIR /var/www/html + +ENTRYPOINT ["/usr/local/bin/smf-entrypoint"] +CMD ["apache2-foreground"] diff --git a/.docker/php/entrypoint.sh b/.docker/php/entrypoint.sh new file mode 100644 index 00000000000..1609dcc31ee --- /dev/null +++ b/.docker/php/entrypoint.sh @@ -0,0 +1,134 @@ +#!/bin/sh +# Prepares the bind-mounted SMF checkout so the forum is ready to install/serve. +# Everything here is idempotent: it is safe to restart the container at any time. +set -eu + +BOARD_DIR=/var/www/html + +log() { + echo "[smf-dev] $*" +} + +# ---------------------------------------------------------------- dependencies +if [ ! -f "$BOARD_DIR/vendor/autoload.php" ]; then + log 'vendor/ is missing, running composer install (this takes a minute the first time)' + composer install \ + --working-dir="$BOARD_DIR" \ + --no-interaction \ + --no-progress \ + --prefer-dist \ + --ansi +fi + +# ------------------------------------------------------------------- database +# SMF_DB_TYPE picks the engine. Both are running; only the one the forum is +# pointed at gets waited for and written into Settings.php. +case "${SMF_DB_TYPE:-mysql}" in + mysql|mysqli|mariadb) + DB_TYPE=mysql + DB_SERVER="${SMF_MYSQL_SERVER:-mysql}" + DB_PORT="${SMF_MYSQL_PORT:-3306}" + ;; + + postgresql|postgres|pgsql) + DB_TYPE=postgresql + DB_SERVER="${SMF_POSTGRES_SERVER:-postgres}" + DB_PORT="${SMF_POSTGRES_PORT:-5432}" + ;; + + *) + log "SMF_DB_TYPE='${SMF_DB_TYPE}' is not a type SMF supports (mysql, postgresql)" + exit 1 + ;; +esac + +log "waiting for ${DB_TYPE} at ${DB_SERVER}:${DB_PORT}" + +if [ "$DB_TYPE" = 'postgresql' ]; then + until pg_isready -h "$DB_SERVER" -p "$DB_PORT" -U "$SMF_DB_USER" -q; do + sleep 1 + done +else + # Any answer at all means the server is listening; this deliberately does + # not authenticate, so it works before the init scripts have finished. + until mysqladmin ping -h "$DB_SERVER" -P "$DB_PORT" --silent >/dev/null 2>&1; do + sleep 1 + done +fi + +log "${DB_TYPE} is accepting connections" + +# --------------------------------------------------------------- installer bits +# Settings.php redirects to install.php whenever install.php is present, so both +# files only get placed while the forum has not been installed yet. +if [ ! -f "$BOARD_DIR/Settings.php" ]; then + log "generating Settings.php pre-filled for the ${DB_TYPE} service" + + sed \ + -e "s|^\$db_type = 'mysql';|\$db_type = '${DB_TYPE}';|" \ + -e "s|^\$db_port = 0;|\$db_port = ${DB_PORT};|" \ + -e "s|^\$db_server = 'localhost';|\$db_server = '${DB_SERVER}';|" \ + -e "s|^\$db_name = 'smf';|\$db_name = '${SMF_DB_NAME}';|" \ + -e "s|^\$db_user = 'root';|\$db_user = '${SMF_DB_USER}';|" \ + -e "s|^\$db_passwd = '';|\$db_passwd = '${SMF_DB_PASSWD}';|" \ + -e "s|^\$boardurl = 'http://127.0.0.1/smf';|\$boardurl = '${SMF_BOARDURL}';|" \ + -e "s|^\$mbname = 'My Community';|\$mbname = 'SMF Dev';|" \ + "$BOARD_DIR/other/Settings.php" > "$BOARD_DIR/Settings.php" + + cp "$BOARD_DIR/other/install.php" "$BOARD_DIR/install.php" + + log "installer ready -- open ${SMF_BOARDURL}/install.php" +else + # Already installed, and Settings.php wins over SMF_DB_TYPE. Say so rather + # than leaving someone wondering why switching the variable did nothing. + # The installer writes this back with its own capitalisation ('PostgreSQL'), + # so compare case-insensitively or the note fires on every restart. + installed_type=$(sed -n "s|^\\\$db_type = '\\([^']*\\)';.*|\\1|p" "$BOARD_DIR/Settings.php" | head -n 1 | tr '[:upper:]' '[:lower:]') + + if [ -n "$installed_type" ] && [ "$installed_type" != "$DB_TYPE" ]; then + log "note: Settings.php is installed against ${installed_type}, not ${DB_TYPE}" + log ' to move, delete Settings.php and Settings_bak.php, then restart' + fi +fi + +[ -f "$BOARD_DIR/Settings_bak.php" ] || cp "$BOARD_DIR/Settings.php" "$BOARD_DIR/Settings_bak.php" + +# ------------------------------------------------------------------ writability +# The installer refuses to continue unless all of these are writable, and the +# forum needs them at runtime too. +for path in \ + attachments \ + avatars \ + custom_avatar \ + cache \ + Packages \ + Smileys \ + Themes \ + Languages \ + Sources \ + Settings.php \ + Settings_bak.php \ + Languages/en_US/agreement.txt +do + [ -e "$BOARD_DIR/$path" ] || mkdir -p "$BOARD_DIR/$path" +done + +[ -f "$BOARD_DIR/cache/db_last_error.php" ] || cp "$BOARD_DIR/db_last_error.php" "$BOARD_DIR/cache/db_last_error.php" 2>/dev/null || true + +# Bind mounts from the Windows host ignore chown/chmod, which is harmless. On +# Linux/macOS hosts these calls are what makes the checkout writable by Apache. +chown -R www-data:www-data \ + "$BOARD_DIR/attachments" \ + "$BOARD_DIR/avatars" \ + "$BOARD_DIR/custom_avatar" \ + "$BOARD_DIR/cache" \ + "$BOARD_DIR/Packages" \ + "$BOARD_DIR/Smileys" \ + "$BOARD_DIR/Themes" \ + "$BOARD_DIR/Languages" \ + "$BOARD_DIR/Settings.php" \ + "$BOARD_DIR/Settings_bak.php" 2>/dev/null || true + +log 'ready' + +exec "$@" diff --git a/.docker/php/msmtprc b/.docker/php/msmtprc new file mode 100644 index 00000000000..ce832dcf3b4 --- /dev/null +++ b/.docker/php/msmtprc @@ -0,0 +1,11 @@ +defaults +auth off +tls off +logfile /dev/stderr + +account mailpit +host mailpit +port 1025 +from smf@localhost + +account default : mailpit diff --git a/.docker/php/php.ini b/.docker/php/php.ini new file mode 100644 index 00000000000..6b32f746e7a --- /dev/null +++ b/.docker/php/php.ini @@ -0,0 +1,34 @@ +; SMF development settings. +; Values follow other/requirements.md (Requirements + Recommendations). + +[PHP] +engine = On +file_uploads = On +memory_limit = 512M +max_execution_time = 30 +max_input_time = 60 +post_max_size = 128M +upload_max_filesize = 128M +max_file_uploads = 50 +date.timezone = UTC + +; Dev-only: surface every problem instead of hiding it. +display_errors = On +display_startup_errors = On +error_reporting = E_ALL +log_errors = On +error_log = /dev/stderr + +[Session] +session.use_trans_sid = Off +session.save_path = "/tmp" + +[mail function] +sendmail_path = "/usr/bin/msmtp -t -i" + +[opcache] +; Keep opcache on for realistic behaviour, but always re-read changed files. +opcache.enable = 1 +opcache.enable_cli = 0 +opcache.validate_timestamps = 1 +opcache.revalidate_freq = 0 diff --git a/.docker/php/vhost.conf b/.docker/php/vhost.conf new file mode 100644 index 00000000000..8be73bae7ce --- /dev/null +++ b/.docker/php/vhost.conf @@ -0,0 +1,19 @@ +ServerName localhost + + + DocumentRoot /var/www/html + + + Options -Indexes +FollowSymLinks + AllowOverride All + Require all granted + + + # Nothing under these paths should ever be served. + + Require all denied + + + ErrorLog /dev/stderr + CustomLog /dev/stdout combined + diff --git a/.docker/postgres/init/10-smf.sh b/.docker/postgres/init/10-smf.sh new file mode 100644 index 00000000000..0498c9270ff --- /dev/null +++ b/.docker/postgres/init/10-smf.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# Runs once, on first initialisation of the postgres data volume. +set -eu + +# SMF requires standard_conforming_strings to be on (other/requirements.md). +# It is already the default on modern postgres; setting it at database level +# makes it explicit so it cannot drift out from under the forum. +psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL + ALTER DATABASE "$POSTGRES_DB" SET standard_conforming_strings = on; +EOSQL + +echo "[smf-dev] database $POSTGRES_DB initialised" diff --git a/.docker/reset.sh b/.docker/reset.sh new file mode 100755 index 00000000000..00f2de5a404 --- /dev/null +++ b/.docker/reset.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# Returns the stack to "installable": no forum, an empty database for the chosen +# engine, and a Settings.php regenerated for it. +# +# .docker/reset.sh --engine mysql +# .docker/reset.sh --engine postgresql +# +# This is also how you move an install between engines. Settings.php pins one +# engine and wins over SMF_DB_TYPE, so switching means throwing it away and +# letting the entrypoint write a new one. To keep an install rather than +# discard it, use use-engine.sh instead. +# +# Only the chosen engine's database is touched. The two engines keep separate +# volumes, so a MySQL reset can never disturb a PostgreSQL install or vice +# versa. +# +# Runs on the host. +set -euo pipefail + +. "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh" + +ENGINE='' +KEEP_FILES=0 + +while [ $# -gt 0 ]; do + case "$1" in + --engine) ENGINE="$2"; shift 2 ;; + --engine=*) ENGINE="${1#*=}"; shift ;; + --keep-files) KEEP_FILES=1; shift ;; + -h|--help) sed -n '2,17p' "${BASH_SOURCE[0]}"; exit 0 ;; + *) die "unknown argument: $1" ;; + esac +done + +[ -n "$ENGINE" ] || die 'need --engine mysql|postgresql' +SERVICE=$(engine_service "$ENGINE") || die "unknown engine: $ENGINE" +SMF_TYPE=$(engine_smf_type "$ENGINE") + +cd "$BOARD_DIR" + +log "resetting for ${SMF_TYPE}" + +# ------------------------------------------------------------------ the forum +# Stop the web container first: Apache holding a half-installed forum open while +# its database vanishes underneath produces confusing errors in the log. +docker compose stop web >/dev/null 2>&1 || true + +rm -f Settings.php Settings_bak.php install.php upgrade.php + +# SMF's cache holds a serialised copy of $modSettings, which would otherwise +# outlive the database it describes. +find cache -type f ! -name 'index.php' ! -name '.htaccess' -delete 2>/dev/null || true + +if [ "$KEEP_FILES" -eq 0 ]; then + for dir in attachments custom_avatar; do + find "$dir" -type f ! -name 'index.php' ! -name '.htaccess' ! -name 'blank.png' -delete 2>/dev/null || true + done + rm -f Packages/installed.list +fi + +# --------------------------------------------------------------- the database +docker compose up -d "$SERVICE" >/dev/null + +if [ "$SMF_TYPE" = 'mysql' ]; then + # As root: the smf user has rights on the smf database but cannot drop and + # recreate it. utf8mb4 matches what compose.yaml asks the server for and + # what SMF's own DDL emits. + docker compose exec -T -e MYSQL_PWD="$DB_ROOT_PASSWORD" "$SERVICE" mysql -uroot -e " + DROP DATABASE IF EXISTS \`${DB_NAME}\`; + CREATE DATABASE \`${DB_NAME}\` CHARACTER SET utf8mb4; + GRANT ALL ON \`${DB_NAME}\`.* TO '${DB_USER}'@'%'; + " +else + # The database itself cannot be dropped while we are connected to it, and + # dropping the schema is enough: it takes the tables, sequences, functions + # and operators with it. smf owns the database, so it may recreate public. + docker compose exec -T "$SERVICE" psql -v ON_ERROR_STOP=1 -q -U "$DB_USER" -d "$DB_NAME" -c ' + DROP SCHEMA IF EXISTS public CASCADE; + CREATE SCHEMA public; + ' >/dev/null +fi + +log "${SMF_TYPE} database ${DB_NAME} is empty" + +# Bring web back up so the entrypoint regenerates Settings.php for this engine +# and stages the installer. +SMF_DB_TYPE="$SMF_TYPE" docker compose up -d web >/dev/null + +# The entrypoint waits for the database before it writes anything, so give it a +# moment to get there rather than racing whatever runs next. +for _ in $(seq 1 60); do + if docker compose exec -T web test -f install.php 2>/dev/null; then + log 'installer staged, ready to install' + exit 0 + fi + sleep 1 +done + +die 'timed out waiting for the entrypoint to stage install.php (docker compose logs web)' diff --git a/.docker/test.sh b/.docker/test.sh new file mode 100755 index 00000000000..8d6d818e206 --- /dev/null +++ b/.docker/test.sh @@ -0,0 +1,87 @@ +#!/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" + + # The HTTP tests sign in, so they need to be told who the administrator is. + # These default to what install-forum.sh created; export them to point the + # suite at a forum that was set up some other way. + if docker compose exec -T \ + -e SMF_ADMIN_USER="$SMF_ADMIN_USER" \ + -e SMF_ADMIN_PASS="$SMF_ADMIN_PASS" \ + web vendor/bin/phpunit --no-coverage --colors=always "${PHPUNIT_ARGS[@]+"${PHPUNIT_ARGS[@]}"}"; then + log "${smf_type}: passed" + else + warn "${smf_type}: FAILED" + 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 00000000000..5fd2b7a96b5 --- /dev/null +++ b/.docker/use-engine.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Switches which installed forum is live, without reinstalling either. +# +# .docker/use-engine.sh mysql +# .docker/use-engine.sh postgresql +# +# Both database services always run, on separate volumes, so each keeps its own +# forum. What decides which one you get is Settings.php: it pins $db_type, and +# it wins over SMF_DB_TYPE. install-forum.sh files a copy per engine, and this +# puts one of them back. +# +# No container restart is needed. The entrypoint only writes Settings.php when +# there is not one, so it leaves whatever is in place alone. +# +# To throw an install away and start over, use reset.sh instead. +# +# Runs on the host. +set -euo pipefail + +. "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh" + +[ $# -eq 1 ] || die 'usage: use-engine.sh mysql|postgresql' + +case "$1" in + -h|--help) sed -n '2,16p' "${BASH_SOURCE[0]}"; exit 0 ;; +esac + +SMF_TYPE=$(engine_smf_type "$1") || die "unknown engine: $1" +SAVED="$SETTINGS_DIR/Settings.${SMF_TYPE}.php" + +cd "$BOARD_DIR" + +[ -f "$SAVED" ] || die "no saved settings for ${SMF_TYPE} -- run .docker/install-forum.sh --engine ${SMF_TYPE}" + +cp "$SAVED" Settings.php +cp "$SETTINGS_DIR/Settings_bak.${SMF_TYPE}.php" Settings_bak.php + +# SMF's cache holds a serialised copy of $modSettings, which describes the +# database we are switching away from. $cache_enable defaults to 0, so there is +# usually nothing there -- but the directory also holds db_last_error.php and +# the generated CSS and JS, and clearing it costs nothing. +find cache -type f ! -name 'index.php' ! -name '.htaccess' -delete 2>/dev/null || true + +VERSION=$(installed_version "$SMF_TYPE" || true) + +[ -n "$VERSION" ] || warn "${SMF_TYPE} has no forum installed -- Settings.php now points at an empty database" + +log "active engine: ${SMF_TYPE}${VERSION:+ (SMF ${VERSION})} -- ${SMF_BOARDURL}" diff --git a/.docker/user.sh b/.docker/user.sh new file mode 100755 index 00000000000..eebf4d1a8e5 --- /dev/null +++ b/.docker/user.sh @@ -0,0 +1,204 @@ +#!/usr/bin/env bash +# Looks at forum accounts and fixes their passwords, so "which password did this +# forum end up with?" does not turn into a session of hand written SQL. +# +# .docker/user.sh list +# .docker/user.sh check admin 'password' +# .docker/user.sh reset admin 'a new password' +# .docker/user.sh check admin 'password' --engine postgresql +# +# check exits 0 when SMF would accept the password and 1 when it would not, so +# it is usable in a conditional as well as by eye. +# +# Everything goes through SMF's own Security class rather than writing a hash +# from here: what this puts in the table is by construction what Login2 expects +# to find there. Nothing is ever printed that would reveal an existing password; +# hashes are one way and this does not try to be clever about that. +# +# Without --engine it acts on the forum Settings.php currently points at. With +# it, it reads the copy use-engine.sh saved for that engine instead, which means +# the other forum can be inspected without switching to it. +# +# Runs on the host. +set -euo pipefail + +. "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh" + +ENGINE='' +ACTION='' +NAME='' +PASSWORD='' +POSITIONAL=() + +while [ $# -gt 0 ]; do + case "$1" in + --engine) ENGINE="$2"; shift 2 ;; + --engine=*) ENGINE="${1#*=}"; shift ;; + -h|--help) sed -n '2,22p' "${BASH_SOURCE[0]}"; exit 0 ;; + -*) die "unknown argument: $1" ;; + *) POSITIONAL+=("$1"); shift ;; + esac +done + +[ "${#POSITIONAL[@]}" -gt 0 ] || die "need an action: list, check or reset (see --help)" + +ACTION="${POSITIONAL[0]}" +NAME="${POSITIONAL[1]:-}" +PASSWORD="${POSITIONAL[2]:-}" + +case "$ACTION" in + list) ;; + check|reset) + [ -n "$NAME" ] || die "${ACTION}: need a member name" + [ -n "$PASSWORD" ] || die "${ACTION}: need a password" + ;; + *) die "unknown action: ${ACTION} (expected list, check or reset)" ;; +esac + +# The settings file to read, as the container sees it. Empty means "whichever +# forum is live", which is the common case and needs no explanation in the log. +SETTINGS='/var/www/html/Settings.php' + +if [ -n "$ENGINE" ]; then + SMF_TYPE=$(engine_smf_type "$ENGINE") || die "unknown engine: $ENGINE" + SAVED="$DOCKER_DIR/settings/Settings.${SMF_TYPE}.php" + + [ -f "$SAVED" ] || die "no saved settings for ${SMF_TYPE}; install it first with install-forum.sh --engine ${SMF_TYPE}" + + SETTINGS="/var/www/html/.docker/settings/Settings.${SMF_TYPE}.php" +fi + +cd "$BOARD_DIR" + +# The password goes through the environment rather than the argument list: +# arguments are visible to anything that can read the process table, and a +# password typed at a shell is quite enough exposure already. +docker compose exec -T \ + -e SMF_USER_ACTION="$ACTION" \ + -e SMF_USER_NAME="$NAME" \ + -e SMF_USER_PASSWORD="$PASSWORD" \ + -e SMF_USER_SETTINGS="$SETTINGS" \ + web php <<-'PHP' + 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 00000000000..398c9f9ae16 --- /dev/null +++ b/.github/workflows/phpunit.yml @@ -0,0 +1,40 @@ +name: PHPUnit + +on: + push: + branches: + - release-3.0 + pull_request: + +jobs: + phpunit: + name: Unit tests + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: [ 8.4, 8.5 ] + + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #4.2.2 + + - name: Setup PHP ${{ matrix.php }} + uses: shivammathur/setup-php@9e72090525849c5e82e596468b86eb55e9cc5401 #2.32.0 + with: + php-version: ${{ matrix.php }} + coverage: none + + - name: Cache Composer packages + id: composer-cache + uses: actions/cache@d4323d4df104b026a6aa633fdb11d772146be0bf #4.2.2 + with: + path: vendor + key: ${{ runner.os }}-php${{ matrix.php }}-${{ hashFiles('**/composer.lock') }} + restore-keys: ${{ runner.os }}-php${{ matrix.php }}- + + - name: Install dependencies + if: steps.composer-cache.outputs.cache-hit != 'true' + run: composer install --prefer-dist --no-progress --ansi + + - name: Run the unit tests + run: vendor/bin/phpunit --no-coverage --colors=always diff --git a/.gitignore b/.gitignore index 8a574e9b8d5..23f4dc34e89 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ attachments/ !/attachments/.htaccess !/attachments/index.php /upgrade.php +/install.php Themes/default/css/minified*.css Themes/default/scripts/minified*.js Themes/default/scripts/minified_deferred*.js @@ -72,6 +73,16 @@ Thumbs.db *.lnk ._* +# Local dev environment # +########################## +/.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 # ######################## /nbproject/private/ @@ -87,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 70a078fe6a9..09fbf5d8363 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,12 +79,145 @@ 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. + +#### HTTP tests + +`tests/Integration/Http/` drives the forum over the wire, through `HttpTestCase`. Use it +when the thing worth proving is that a *page* works: the session, the cookies, the theme +and the templates are all in the path, and none of them are otherwise reachable. + +They cannot use a transaction and do not try to - see `HttpTestCase::usesTransaction()` - +so a test that writes cleans up after itself. Four things about SMF make writing them +harder than it looks, all of them handled in the base class: + +- **Arrive at the forum before submitting anything.** The first request of a new session + regenerates it, so a security token minted on the very first page a visitor sees can + never be validated. The symptom is a 403 "Token verification failed" that looks like a + broken token rather than a replaced session. +- **Send the button you mean to press.** `HttpResponse::formFields()` deliberately leaves + buttons out. The posting form has both `preview` and `post`; submitting the pair means + preview wins, the post is never made, and the response is a perfectly ordinary 200. +- **Flood control will hit you.** `Security::spamProtection()` allows a moderator one + login or post every two seconds per IP, and tests are far faster than people. + `submitForm()` waits it out once rather than failing at random. +- **Quote `errorText()` in failure messages, not the body.** A fatal error in SMF is a + normal page, and its first few hundred characters are the menu. + +**Run both engines.** This is not thoroughness for its own sake — the two disagree often +enough to matter. `ModSettingsTest` pins a bug that *passes on MySQL with the bug still +in place*, because MySQL silently coerces text to a number where PostgreSQL refuses. +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 @@ -115,10 +248,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 3f3f4ee4477..21dceeb9f5b 100644 --- a/Languages/en_US/Maintenance.php +++ b/Languages/en_US/Maintenance.php @@ -124,6 +124,7 @@ It is recommended that you visit the Simple Machines website to ensure you are installing the latest version.'; $txt['error_already_installed'] = 'The installer has detected that you already have SMF installed. It is strongly advised that you do not try to overwrite an existing installation, continuing with installation may result in the loss or corruption of existing data.

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

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

Please make sure you uploaded the entire installation package, including the sql file, and then try again.'; $txt['error_session_save_path'] = 'Please inform your host that the session.save_path specified in php.ini is not valid! It needs to be changed to a directory that exists and is writable by the user PHP is running under.
'; diff --git a/Sources/ActionTrait.php b/Sources/ActionTrait.php index cacd323b991..5cadd786c9e 100644 --- a/Sources/ActionTrait.php +++ b/Sources/ActionTrait.php @@ -115,7 +115,10 @@ public function canShowDebuggingInfo(): bool */ public static function load(): static { - if (!isset(static::$obj)) { + // A static property is shared with every descendant class that doesn't + // redeclare it, so $obj might currently hold an instance of a relative + // of this class rather than an instance of this class itself. + if (!isset(static::$obj) || static::$obj::class !== static::class) { static::$obj = new static(); } diff --git a/Sources/Maintenance/Maintenance.php b/Sources/Maintenance/Maintenance.php index 8809ee6f5fb..8c47056f45d 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 171fd99fa2b..73d9d286228 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'); @@ -1418,7 +1448,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 7193b974371..d8c0a0be4d4 100644 --- a/Sources/Maintenance/Tools/ToolsBase.php +++ b/Sources/Maintenance/Tools/ToolsBase.php @@ -612,10 +612,11 @@ public function updateSettingsFile(array $config_vars, ?bool $keep_quotes = null if (!Config::updateSettingsFile($config_vars, $keep_quotes, $rebuild)) { $this->logProgress(Lang::getTxt('log_failed_with_error', ['error' => Lang::getTxt('settings_error', file: 'Maintenance')], file: 'Maintenance')); - if (Sapi::isCLI()) { - die(); - } - + // This used to die() outright on the command line, which reported + // nothing and exited 0 -- a scripted install that could not write + // Settings.php looked exactly like one that worked. Recording the + // error and returning stops the run just as firmly, and now the + // caller gets to say why. Maintenance::$fatal_error = Lang::getTxt('settings_error', file: 'Maintenance'); return false; diff --git a/Sources/Sapi.php b/Sources/Sapi.php index 2e6aba3779e..5ff927e1d0f 100644 --- a/Sources/Sapi.php +++ b/Sources/Sapi.php @@ -334,19 +334,28 @@ public static function setMemoryLimit(string $needed, bool $in_use = false): boo /** * Helper function to convert memory string settings to bytes * - * @param string $val The byte string, like '256M' or '1G'. + * The shorthand notation PHP accepts for these settings is optional, so the + * value may be a plain byte count with no designator at all. + * + * A negative value means there is no limit, which is reported as PHP_INT_MAX + * so that callers comparing it against an amount of memory they need do not + * have to special case it. + * + * @param string $val The byte string, like '256M', '1G' or '2097152'. * @return int The string converted to a proper integer in bytes. */ public static function memoryReturnBytes(string $val): int { - if (\is_integer($val)) { - return (int) $val; + $val = trim($val); + + // No limit at all. + if ((int) $val < 0) { + return PHP_INT_MAX; } - // Separate the number from the designator. - $val = trim($val); - $num = \intval(substr($val, 0, \strlen($val) - 1)); + // Separate the number from the designator, if there is one. $last = strtolower(substr($val, -1)); + $num = \in_array($last, ['g', 'm', 'k'], true) ? (int) substr($val, 0, -1) : (int) $val; // Convert to bytes. switch ($last) { diff --git a/Sources/Url.php b/Sources/Url.php index ae3421dfe36..b98c3c1f599 100644 --- a/Sources/Url.php +++ b/Sources/Url.php @@ -600,12 +600,23 @@ public function isWebsite(): bool /** * Check if this URL uses one of the specified schemes. * + * Scheme names are case insensitive, per RFC 3986, section 3.1, and this + * class does not normalize them, so both sides are folded before comparing. + * * @param string|string[] $scheme Schemes to check. * @return bool Whether the URL matches a scheme. */ public function isScheme(string|array $scheme): bool { - return !empty($this->scheme) && \in_array($this->scheme, array_map('strval', (array) $scheme)); + if (empty($this->scheme)) { + return false; + } + + return \in_array( + strtolower($this->scheme), + array_map(fn($s) => strtolower((string) $s), (array) $scheme), + true, + ); } /** diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 00000000000..cac7f51584f --- /dev/null +++ b/compose.yaml @@ -0,0 +1,127 @@ +# SMF development environment. +# +# docker compose up -d --build +# -> http://localhost:8080/install.php +# +# Both database engines SMF supports are running. MySQL is what the forum is +# pointed at by default; set SMF_DB_TYPE=postgresql in .env to use the other. +# +# Everything below has a working default, so no .env file is required. Copy +# .docker/env.example to .env if you want to change ports or credentials. + +name: smf-dev + +services: + web: + build: + context: . + dockerfile: .docker/php/Dockerfile + args: + PHP_VERSION: ${PHP_VERSION:-8.4} + ports: + - "${WEB_PORT:-8080}:80" + volumes: + # The checkout is bind-mounted, so edits on the host are live immediately. + - .:/var/www/html + environment: + # Which engine the generated Settings.php points at: mysql or postgresql. + SMF_DB_TYPE: ${SMF_DB_TYPE:-mysql} + SMF_DB_NAME: ${DB_NAME:-smf} + SMF_DB_USER: ${DB_USER:-smf} + SMF_DB_PASSWD: ${DB_PASSWORD:-smf} + # Per-engine host and port, so switching SMF_DB_TYPE is the only change + # needed. These are container-internal, not the host ports below. + SMF_MYSQL_SERVER: mysql + SMF_MYSQL_PORT: "3306" + SMF_POSTGRES_SERVER: postgres + SMF_POSTGRES_PORT: "5432" + SMF_BOARDURL: http://localhost:${WEB_PORT:-8080} + depends_on: + mysql: + condition: service_healthy + postgres: + condition: service_healthy + restart: unless-stopped + + mysql: + image: mysql:${MYSQL_VERSION:-8.4} + ports: + # Exposed on 3307 by default so it cannot collide with a local mysql. + - "${MYSQL_PORT:-3307}:3306" + environment: + MYSQL_DATABASE: ${DB_NAME:-smf} + MYSQL_USER: ${DB_USER:-smf} + MYSQL_PASSWORD: ${DB_PASSWORD:-smf} + MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-smf} + # SMF creates its tables as InnoDB/utf8mb4, so match that at server level + # rather than relying on whichever defaults the image ships with. The + # collation is deliberately left to the charset default: SMF's own DDL says + # `DEFAULT CHARSET=utf8mb4` with no COLLATE, so forcing a different one here + # would only apply to objects SMF did not create, and the two would diverge. + command: + - --character-set-server=utf8mb4 + - --default-storage-engine=InnoDB + volumes: + - mysql-data:/var/lib/mysql + - ./.docker/mysql/init:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 --silent"] + interval: 3s + timeout: 5s + retries: 40 + start_period: 20s + restart: unless-stopped + + postgres: + image: postgres:${POSTGRES_VERSION:-17-alpine} + # Also reachable as `db`, which is what installs made before MySQL was + # added have in their Settings.php. + networks: + default: + aliases: + - db + ports: + # Exposed on 5433 by default so it cannot collide with a local postgres. + - "${POSTGRES_PORT:-5433}:5432" + environment: + POSTGRES_DB: ${DB_NAME:-smf} + POSTGRES_USER: ${DB_USER:-smf} + POSTGRES_PASSWORD: ${DB_PASSWORD:-smf} + # Dev-only: skip the password prompt cost for local connections. + POSTGRES_HOST_AUTH_METHOD: scram-sha-256 + volumes: + - db-data:/var/lib/postgresql/data + - ./.docker/postgres/init:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-smf} -d ${DB_NAME:-smf}"] + interval: 3s + timeout: 3s + retries: 20 + restart: unless-stopped + + # Catches every mail the forum sends. Nothing leaves the machine. + mailpit: + image: axllent/mailpit:latest + ports: + - "${MAILPIT_PORT:-8025}:8025" + restart: unless-stopped + + # Browse and query either database at http://localhost:8081. The server field + # is pre-filled with whichever engine the forum is using; type the other + # service name in to look at it instead. + adminer: + image: adminer:latest + ports: + - "${ADMINER_PORT:-8081}:8080" + environment: + # Service name, not engine name: mysql or postgres. + ADMINER_DEFAULT_SERVER: ${ADMINER_SERVER:-mysql} + ADMINER_DESIGN: dracula + depends_on: + - mysql + - postgres + restart: unless-stopped + +volumes: + db-data: + mysql-data: diff --git a/composer.json b/composer.json index c08fcb38487..8c9352d4aa0 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 0f762a87ed6..c092fa442bb 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 00000000000..720240fd3c8 --- /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 00000000000..ac7ceb8ffcd --- /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/Http/GuestPagesTest.php b/tests/Integration/Http/GuestPagesTest.php new file mode 100644 index 00000000000..7c321f9fba3 --- /dev/null +++ b/tests/Integration/Http/GuestPagesTest.php @@ -0,0 +1,168 @@ +fetch($path); + + $this->assertLooksLikeAForumPage($response, $name); + $this->assertNoErrorsLogged($name . ' (' . $path . ') logged something.' . "\n"); + } + + public function testTheBoardIndexListsAtLeastOneBoard(): void + { + $response = $this->fetch(''); + + $this->assertGreaterThan( + 0, + $response->xpath('//a[contains(@href, "board=")]')->length, + 'the board index links to no boards, so a fresh install has nothing in it', + ); + + $this->assertNoErrorsLogged(); + } + + /** + * The one page here that is not HTML. It is worth its place because the feed + * is built by hand rather than by the template layer, so nothing else in this + * file would notice it breaking. + */ + public function testTheFeedIsXmlAndParses(): void + { + $response = $this->fetch('?action=.xml;type=rss2'); + + $this->assertStringContainsString( + 'xml', + strtolower($response->headers['content-type'] ?? ''), + 'the feed did not come back as XML', + ); + + $previous = libxml_use_internal_errors(true); + $parsed = simplexml_load_string($response->body); + libxml_clear_errors(); + libxml_use_internal_errors($previous); + + $this->assertNotFalse($parsed, 'the feed is not well formed XML'); + $this->assertNoErrorsLogged('the feed logged something.' . "\n"); + } + + /** + * An action that does not exist should be a 404, not a 200 with an apology + * and not a 500. + */ + public function testAnUnknownActionIsNotFound(): void + { + $this->fetch('?action=smf_tests_no_such_action', 404); + } + + /** + * Registration refuses to start for a visitor who sends no cookies at all, + * because a registration that cannot keep a session cannot be completed. + * + * It is in its own test rather than the sweep above because it is the one + * page here that a cold request is genuinely not allowed to reach, and the + * difference between the two halves is worth stating: arriving at the forum + * first is what makes it work, and that is what a browser does. + */ + public function testRegistrationNeedsASessionFirst(): void + { + $this->http->forgetCookies(); + + $cold = $this->http->get('?action=signup'); + + $this->assertSame(403, $cold->status, 'a cookieless visitor was allowed into registration'); + + // Arrive at the forum the way a person would, which sets the session + // cookie, and then go to register. + $this->fetch(''); + + $agreement = $this->fetch('?action=signup'); + + $this->assertLooksLikeAForumPage($agreement, 'the registration agreement'); + + // requireAgreement is on by default, so step one is the agreement rather + // than the form. Note the form has to be named: the first form on any SMF + // page is the search box in the header. + $registration_form = '//form[contains(@action, "action=signup")]'; + + $this->assertGreaterThan( + 0, + $agreement->xpath($registration_form . '//input[@name="accept_agreement"]')->length, + 'registration did not start at the agreement', + ); + + // Buttons are not submitted unless named, so say which one we press. + $form = $this->http->submit($agreement, [ + 'accept_agreement' => 'I accept the terms of the agreement.', + ], $registration_form); + + $this->assertSame(200, $form->status, 'accepting the agreement returned ' . $form->status); + + $this->assertGreaterThan( + 0, + $form->xpath('//input[@name="user"]')->length, + 'accepting the agreement did not lead to the registration form', + ); + + $this->assertNoErrorsLogged('registering logged something.' . "\n"); + } + + /*********************** + * Public static methods + ***********************/ + + /** + * The pages a guest can reach on a stock install. + * + * Deliberately only actions that a fresh forum can serve without any content + * having been created and without being logged in, so this stays green on a + * forum straight out of .docker/install-forum.sh. + * + * @return array The cases, path and a readable name. + */ + public static function guestPages(): array + { + return [ + 'board index' => ['', 'the board index'], + 'help' => ['?action=help', 'help'], + 'login form' => ['?action=login', 'the login form'], + 'recent posts' => ['?action=recent', 'recent posts'], + 'unread' => ['?action=unread', 'unread posts'], + 'search form' => ['?action=search', 'the search form'], + 'member list' => ['?action=mlist', 'the member list'], + 'statistics' => ['?action=stats', 'the statistics page'], + 'credits' => ['?action=credits', 'the credits page'], + 'who is online' => ['?action=who', 'who is online'], + 'agreement' => ['?action=agreement', 'the registration agreement'], + 'first board' => ['?board=1.0', 'the first board'], + ]; + } +} diff --git a/tests/Integration/Http/HttpTestCase.php b/tests/Integration/Http/HttpTestCase.php new file mode 100644 index 00000000000..a85e423c6c4 --- /dev/null +++ b/tests/Integration/Http/HttpTestCase.php @@ -0,0 +1,274 @@ +http = new HttpClient(); + + // Arrive at the forum before doing anything else, which is what a person + // does and what the tests below depend on. + // + // The very first request of a new session regenerates it - SMF sets a + // guest login cookie, and Cookie::setLoginCookie() throws the session + // away and starts another whenever that value changes. Anything minted + // earlier in that same request is minted against the session that just + // went away, so a security token taken from the first page a visitor + // ever sees can never be validated. Posting that form comes back 403, + // "Token verification failed", with nothing to suggest the token was + // fine and the session underneath it was not. + $this->http->get(''); + } + + /** + * Signs in as the forum administrator. + * + * The credentials are the ones .docker/install-forum.sh uses, overridable + * through the environment for a forum that was set up some other way. + * + * @return HttpResponse The response to the login post. + */ + protected function signInAsAdmin(): HttpResponse + { + $response = $this->attemptSignIn(); + + if (self::isThrottled($response)) { + sleep(self::FLOOD_WAIT); + + $response = $this->attemptSignIn(); + } + + // A password that does not match is a misconfigured forum rather than a + // regression, and failing every test in the file over it would say + // nothing useful. Skipping names the variable to set. + if (str_contains($response->text(), 'username or password you entered is incorrect')) { + self::markTestSkipped( + 'cannot sign in as "' . self::adminName() . '". Set SMF_ADMIN_USER and ' + . 'SMF_ADMIN_PASS to this forum\'s administrator, or reinstall with ' + . '.docker/install-forum.sh --engine mysql --force', + ); + } + + $this->assertLessThan( + 400, + $response->status, + 'logging in returned ' . $response->status . ': ' . $response->errorText(), + ); + + return $response; + } + + /** + * Submits a form, waiting out flood control if it gets in the way. + * + * Tests do in half a second what a person would take a minute over, so they + * trip SMF's flood protection routinely. That is the forum working, not a + * regression, and the difference between a suite people trust and one that + * fails now and then for reasons nobody can reproduce. + * + * @param HttpResponse $page The page holding the form. + * @param array $overrides Values to change or add, including the button. + * @param string $xpath Which form. + * @return HttpResponse The response. + */ + protected function submitForm(HttpResponse $page, array $overrides, string $xpath): HttpResponse + { + $response = $this->http->submit($page, $overrides, $xpath); + + if (!self::isThrottled($response)) { + return $response; + } + + sleep(self::FLOOD_WAIT); + + // The page has to be fetched again rather than resubmitted: its security + // token was spent on the attempt that just bounced. + return $this->http->submit($this->http->get($page->url), $overrides, $xpath); + } + + /** + * Asserts the client is, or is not, signed in. + * + * Uses the logout link, which the theme only renders for a member. + * + * @param bool $expected Whether we should be signed in. + * @param string $message What was being checked. + */ + protected function assertSignedIn(bool $expected, string $message = ''): void + { + $signed_in = $this->fetch('')->xpath('//a[contains(@href, "action=logout")]')->length > 0; + + $this->assertSame($expected, $signed_in, $message !== '' ? $message : ($expected ? 'not signed in' : 'still signed in')); + } + + /** + * Fetches a page and asserts it came back whole. + * + * @param string $path Where to go, as HttpClient::get() takes it. + * @param int $expected The status it should return. + * @return HttpResponse The response, for further assertions. + */ + protected function fetch(string $path, int $expected = 200): HttpResponse + { + $response = $this->http->get($path); + + $this->assertSame( + $expected, + $response->status, + $path . ' returned ' . $response->status . ' from ' . $this->http->base_url, + ); + + return $response; + } + + /** + * Asserts a page is a real forum page rather than an error SMF rendered + * with a 200. + * + * A fatal error in SMF is a normal page with an apologetic message in it, so + * the status code alone proves very little. + * + * @param HttpResponse $response The response to check. + * @param string $where What was being fetched, for the failure message. + */ + protected function assertLooksLikeAForumPage(HttpResponse $response, string $where): void + { + $this->assertNotSame('', $response->title(), $where . ' has no '); + + $this->assertGreaterThan( + 0, + $response->xpath('//div[@id="footer"] | //footer | //*[@id="bot"]')->length, + $where . ' has no footer, so the template did not finish rendering', + ); + + $this->assertSame( + 0, + $response->xpath('//*[contains(@class, "errorbox")]')->length, + $where . ' rendered an error box: ' . $response->errorText(), + ); + } + + /** + * One go at the login form. + * + * @return HttpResponse The response to the post. + */ + private function attemptSignIn(): HttpResponse + { + $form = $this->fetch('?action=login'); + + return $this->http->submit($form, [ + 'user' => self::adminName(), + 'passwrd' => self::adminPassword(), + ], '//form[contains(@action, "action=login2")]'); + } + + /************************* + * Internal static methods + *************************/ + + /** + * Whether a response is SMF turning us away for going too fast. + * + * @param HttpResponse $response The response to look at. + * @return bool Whether flood control rejected it. + */ + protected static function isThrottled(HttpResponse $response): bool + { + $error = $response->errorText(); + + return str_contains($error, 'You will have to wait') + || str_contains($error, 'The last posting from your IP'); + } +} diff --git a/tests/Integration/Http/LoginTest.php b/tests/Integration/Http/LoginTest.php new file mode 100644 index 00000000000..48b7ba77555 --- /dev/null +++ b/tests/Integration/Http/LoginTest.php @@ -0,0 +1,99 @@ +<?php + +declare(strict_types=1); + +namespace SMF\Tests\Integration\Http; + +use PHPUnit\Framework\Attributes\CoversNothing; +use SMF\Config; + +/** + * Signing in over HTTP. + * + * Worth testing this way rather than through User::setMe(), which is what the + * rest of the integration suite uses: the parts most likely to break are exactly + * the ones setMe() skips. The session has to survive between requests, the + * security token minted with the form has to still be valid when it comes back, + * the cookie has to be signed with $auth_secret, and a post that arrives without + * a session check has to be turned away. + */ +#[CoversNothing] +class LoginTest extends HttpTestCase +{ + /**************** + * Public methods + ****************/ + + public function testTheAdministratorCanSignIn(): void + { + $this->signInAsAdmin(); + + $this->assertSignedIn(true, 'the session did not survive the login redirect'); + $this->assertNoErrorsLogged('signing in logged something.' . "\n"); + } + + /** + * The cookie is what carries the login between requests, so it is worth + * checking it was issued rather than inferring it from the page changing. + */ + public function testSigningInIssuesTheForumCookie(): void + { + $response = $this->signInAsAdmin(); + + $this->assertNotEmpty($response->set_cookies, 'logging in set no cookie at all'); + + // Note the plural: the response carries the session cookie as well, and + // keeping only the last one would test whichever happened to come second. + $this->assertStringContainsString( + (string) Config::$cookiename, + implode("\n", $response->set_cookies), + 'the forum cookie was not among those set: ' . implode(' | ', $response->set_cookies), + ); + } + + public function testTheWrongPasswordDoesNotSignAnyoneIn(): void + { + $form = $this->fetch('?action=login'); + + $this->http->submit($form, [ + 'user' => self::adminName(), + 'passwrd' => 'definitely not the password', + ], '//form[contains(@action, "action=login2")]'); + + $this->assertSignedIn(false, 'a wrong password signed us in anyway'); + } + + /** + * A post carrying no session check should be turned away. This is the guard + * that stops another site from posting to the forum on a visitor's behalf, + * and nothing that does not go over HTTP can exercise it. + */ + public function testAPostWithoutTheSessionCheckIsRejected(): void + { + // Hand built rather than submitted from the form, so none of the session + // fields the form carries are included. + $this->http->post('?action=login2', [ + 'user' => self::adminName(), + 'passwrd' => self::adminPassword(), + ]); + + $this->assertSignedIn(false, 'a login with no session check was accepted'); + } + + public function testSigningOutEndsTheSession(): void + { + $this->signInAsAdmin(); + $this->assertSignedIn(true); + + $page = $this->fetch(''); + $logout = $page->xpath('//a[contains(@href, "action=logout")]')->item(0); + + $this->assertNotNull($logout, 'no logout link to follow'); + + // The link carries its own session check in the query string. + $this->http->get((string) $logout?->attributes?->getNamedItem('href')?->nodeValue); + + $this->assertSignedIn(false, 'still signed in after logging out'); + $this->assertNoErrorsLogged('logging out logged something.' . "\n"); + } +} diff --git a/tests/Integration/Http/PostingTest.php b/tests/Integration/Http/PostingTest.php new file mode 100644 index 00000000000..8240481b32c --- /dev/null +++ b/tests/Integration/Http/PostingTest.php @@ -0,0 +1,222 @@ +<?php + +declare(strict_types=1); + +namespace SMF\Tests\Integration\Http; + +use PHPUnit\Framework\Attributes\CoversNothing; +use SMF\Topic; + +/** + * Starting a topic and replying to it, over HTTP. + * + * This is the journey the forum exists for, and the one with the most behind it: + * the editor, the session check, the security token, permissions, the post + * itself, and then every counter and index SMF updates afterwards. Nothing short + * of a real request covers that. + * + * These tests write, and an HTTP test cannot be rolled back - the request runs in + * the web server's process on its own connection. So whatever they create, they + * remove again in tearDown through SMF's own Topic::remove(), which puts the + * board and member counts back the way deleting a topic in the browser would. + */ +#[CoversNothing] +class PostingTest extends HttpTestCase +{ + /********************* + * Internal properties + *********************/ + + /** + * @var array Topics this test created, to be removed afterwards. + */ + private array $created_topics = []; + + /**************** + * Public methods + ****************/ + + public function testStartingATopicAndReplyingToIt(): void + { + $this->signInAsAdmin(); + + $subject = 'Integration test topic ' . bin2hex(random_bytes(6)); + $body = 'Posted by the integration suite at ' . date('c') . '.'; + + $topic_id = $this->startTopic($subject, $body); + + $topic = $this->fetch('?topic=' . $topic_id . '.0'); + + $this->assertStringContainsString( + $subject, + $topic->text(), + 'the new topic does not show its own subject', + ); + + $this->assertStringContainsString($body, $topic->text(), 'the post body is missing'); + + // And now a reply, which goes through a different form on a different + // page and updates a different set of counters. + $reply = 'A reply from the integration suite.'; + + $form = $this->fetch('?action=post;topic=' . $topic_id . '.0'); + + $posted = $this->submitForm($form, [ + 'subject' => 'Re: ' . $subject, + 'message' => $reply, + 'post' => 'Post', + ], '//form[contains(@action, "action=post2")]'); + + $this->assertLessThan( + 400, + $posted->status, + 'replying returned ' . $posted->status . ': ' . $posted->errorText(), + ); + + $after = $this->fetch('?topic=' . $topic_id . '.0'); + + $this->assertStringContainsString($reply, $after->text(), 'the reply is not on the topic'); + + $this->assertSame( + 2, + $this->countMessages($topic_id), + 'the topic should hold the first post and the reply', + ); + + $this->assertNoErrorsLogged('posting logged something.' . "\n"); + } + + /** + * A guest cannot post on a stock install, and the forum should say so rather + * than accept it. + */ + public function testAGuestCannotStartATopic(): void + { + $before = $this->countTopicsInBoard(1); + + $this->http->post('?action=post2;board=1', [ + 'subject' => 'Integration test guest post', + 'message' => 'This should not be accepted.', + ]); + + $this->assertSame( + $before, + $this->countTopicsInBoard(1), + 'a guest with no session check managed to start a topic', + ); + } + + /****************** + * Internal methods + ******************/ + + protected function tearDown(): void + { + // Before the parent runs, while the connection is still ours. + if ($this->created_topics !== []) { + Topic::remove($this->created_topics); + + $this->created_topics = []; + } + + parent::tearDown(); + } + + /** + * Starts a topic in the first board and returns its id. + * + * @param string $subject The subject. + * @param string $body The message. + * @return int The new topic's id. + */ + private function startTopic(string $subject, string $body): int + { + $before = $this->latestTopicId(); + + $form = $this->fetch('?action=post;board=1.0'); + + $this->assertGreaterThan( + 0, + $form->xpath('//form[contains(@action, "action=post2")]')->length, + 'there is no posting form on the new topic page', + ); + + $posted = $this->submitForm($form, [ + 'subject' => $subject, + 'message' => $body, + // The button we are pressing. Without it the form's other button, + // "preview", is the one SMF acts on. + 'post' => 'Post', + ], '//form[contains(@action, "action=post2")]'); + + $this->assertLessThan( + 400, + $posted->status, + 'posting returned ' . $posted->status . ': ' . $posted->errorText(), + ); + + $topic_id = $this->latestTopicId(); + + // Quote the page when this fails. SMF answers a rejected post with a + // perfectly ordinary 200 and the reason in a box, so without this the + // only evidence is a topic id that did not move. + $this->assertGreaterThan( + $before, + $topic_id, + 'no new topic appeared after posting. The forum said: ' + . ($posted->errorText() !== '' ? $posted->errorText() : '(nothing) - page title "' . $posted->title() . '"'), + ); + + $this->created_topics[] = $topic_id; + + return $topic_id; + } + + /** + * The highest topic id in the forum. + * + * @return int The id, or 0 when there are no topics. + */ + private function latestTopicId(): int + { + $row = $this->queryRow('SELECT COALESCE(MAX(id_topic), 0) AS id FROM {db_prefix}topics'); + + return (int) ($row['id'] ?? 0); + } + + /** + * How many messages a topic holds. + * + * @param int $topic_id The topic. + * @return int The number of messages. + */ + private function countMessages(int $topic_id): int + { + $row = $this->queryRow( + 'SELECT COUNT(*) AS total + FROM {db_prefix}messages + WHERE id_topic = {int:topic}', + ['topic' => $topic_id], + ); + + return (int) ($row['total'] ?? 0); + } + + /** + * How many topics a board holds. + * + * @param int $board_id The board. + * @return int The number of topics. + */ + private function countTopicsInBoard(int $board_id): int + { + $row = $this->queryRow( + 'SELECT COUNT(*) AS total + FROM {db_prefix}topics + WHERE id_board = {int:board}', + ['board' => $board_id], + ); + + return (int) ($row['total'] ?? 0); + } +} diff --git a/tests/Integration/Http/index.php b/tests/Integration/Http/index.php new file mode 100644 index 00000000000..2844a3b9e7b --- /dev/null +++ b/tests/Integration/Http/index.php @@ -0,0 +1,8 @@ +<?php + +// Try to handle it with the upper level index.php. (it should know what to do.) +if (file_exists(dirname(__DIR__) . DIRECTORY_SEPARATOR . 'index.php')) { + include dirname(__DIR__) . DIRECTORY_SEPARATOR . 'index.php'; +} else { + exit; +} diff --git a/tests/Integration/Installation.php b/tests/Integration/Installation.php new file mode 100644 index 00000000000..4505b2675f8 --- /dev/null +++ b/tests/Integration/Installation.php @@ -0,0 +1,111 @@ +<?php + +declare(strict_types=1); + +namespace SMF\Tests\Integration; + +use SMF\Config; +use SMF\Db\DatabaseApi as Db; +use SMF\Infrastructure\Container; + +/** + * Finds the forum the integration suite runs against. + * + * The unit bootstrap deliberately stops short of a database. This carries on + * from where it left off - reads Settings.php, connects, loads $modSettings - + * but only when an integration test actually asks, so `composer test` on a + * machine with no forum still costs nothing. + * + * It never throws. When there is nothing to test against it says why, and + * IntegrationTestCase turns that into a skip rather than a failure. + * + * To get a forum: .docker/install-forum.sh --engine mysql + */ +final class Installation +{ + /**************************** + * Internal static properties + ****************************/ + + /** + * @var string|null Why the forum is unusable, '' when it is fine, null + * before anything has looked. + */ + private static ?string $unavailable = null; + + /*********************** + * Public static methods + ***********************/ + + /** + * Connects, once per process. + * + * @return string Empty when there is a forum to test against, otherwise the + * reason there is not. + */ + public static function unavailableReason(): string + { + // Db::load() hands back the connection it already made and there is no + // way to drop it, so this can only usefully run once anyway. + if (self::$unavailable === null) { + self::$unavailable = self::connect(); + } + + return self::$unavailable; + } + + /************************* + * Internal static methods + *************************/ + + /** + * Does the actual work of unavailableReason(). + * + * @return string Empty on success, otherwise the reason. + */ + private static function connect(): string + { + if (!is_file(SMF_SETTINGS_FILE) || filesize(SMF_SETTINGS_FILE) === 0) { + return 'there is no Settings.php'; + } + + try { + Config::load(); + } catch (\Throwable $e) { + return 'Settings.php could not be loaded: ' . $e->getMessage(); + } + + if (empty(Config::$db_type) || empty(Config::$db_name)) { + return 'Settings.php names no database'; + } + + // index.php builds this before anything can ask for a service. + Container::init(); + + try { + // non_fatal, or a refused connection ends the process with SMF's own + // database error page instead of letting us report it here. + Db::load(['non_fatal' => true]); + } catch (\Throwable $e) { + return 'could not connect to ' . Config::$db_type . ': ' . $e->getMessage(); + } + + if (!isset(Db::$db->connection)) { + return 'could not connect to ' . Config::$db_type . ' as ' . Config::$db_user; + } + + // Connecting is not the same as finding a forum: the dev environment + // writes a Settings.php long before anything is installed behind it. + try { + Config::reloadModSettings(); + } catch (\Throwable $e) { + return 'the database holds no forum: ' . $e->getMessage(); + } + + if (empty(Config::$modSettings['smfVersion'])) { + return 'the database holds no forum (no smfVersion in ' . Config::$db_prefix . 'settings)'; + } + + return ''; + } +} diff --git a/tests/Integration/IntegrationTestCase.php b/tests/Integration/IntegrationTestCase.php new file mode 100644 index 00000000000..b540e9158ee --- /dev/null +++ b/tests/Integration/IntegrationTestCase.php @@ -0,0 +1,290 @@ +<?php + +declare(strict_types=1); + +namespace SMF\Tests\Integration; + +use PHPUnit\Framework\TestCase; +use SMF\Config; +use SMF\Db\DatabaseApi as Db; +use SMF\IntegrationHook; +use SMF\User; + +/** + * Base class for tests that need a real, installed forum. + * + * Everything here exists because the unit suite cannot have it: a database + * connection, $modSettings as the forum actually stores it, and a current user. + * + * Each test runs inside a transaction that is rolled back afterwards, so tests + * can write freely without ordering themselves around each other. Two things + * that does not cover: + * + * - DDL. MySQL commits implicitly on CREATE, ALTER and DROP, so a test that + * changes the schema has to put it back itself. + * - Anything a test causes to happen in another process, such as a request made + * over HTTP. That runs on its own connection and will not see the open + * transaction, nor be undone by the rollback. + */ +abstract class IntegrationTestCase extends TestCase +{ + /********************* + * Internal properties + *********************/ + + /** + * @var array Copy of Config::$modSettings taken before the test ran. + */ + private array $mod_settings_backup = []; + + /** + * @var int Highest id_error before the test ran. + */ + private int $error_watermark = 0; + + /*********************** + * Public static methods + ***********************/ + + public static function setUpBeforeClass(): void + { + $reason = Installation::unavailableReason(); + + if ($reason !== '') { + self::markTestSkipped( + 'no forum to test against: ' . $reason + . '. Run .docker/install-forum.sh --engine mysql', + ); + } + } + + /****************** + * Internal methods + ******************/ + + /** + * Whether to wrap the test in a transaction that is rolled back afterwards. + * + * Override and return false when the test causes work to happen in another + * process - a request made over HTTP, say. That runs on its own connection, + * so the transaction cannot undo it, and on MySQL, whose default isolation + * level is REPEATABLE READ, this connection would go on reading the snapshot + * it took before the request and never see what the request wrote. + * + * @return bool True to use a transaction, which is what most tests want. + */ + protected function usesTransaction(): bool + { + return true; + } + + protected function setUp(): void + { + parent::setUp(); + + if ($this->usesTransaction()) { + Db::$db->transaction('begin'); + } + + // $modSettings is a plain static array, so a test that calls + // updateModSettings() changes it for everything that runs after it. The + // 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 + { + if ($this->usesTransaction()) { + Db::$db->transaction('rollback'); + } + + Config::$modSettings = $this->mod_settings_backup; + + // Hooks added with permanent: false live in $modSettings, so restoring it + // above has already removed them. This only puts the switch back. + IntegrationHook::$enabled = true; + + parent::tearDown(); + } + + /** + * Becomes the given member for the rest of the test. + * + * This is the seam Login2::DoLogin() itself uses once it has checked the + * password, so everything downstream - permissions, bans, logging - behaves + * as it would for a real login, with no cookie and no request involved. + * + * Note that User::$me is a typed static and cannot be unset once assigned, + * so this outlives the test. Say who you are rather than assuming. + * + * @param int $id The member to become. + */ + protected function actingAs(int $id): void + { + User::setMe($id); + } + + /** + * The id of an administrator, for actingAs(). + * + * @return int The lowest member id in group 1. + */ + protected function adminId(): int + { + $request = Db::$db->query( + 'SELECT id_member + FROM {db_prefix}members + WHERE id_group = {int:admin_group} + ORDER BY id_member + LIMIT 1', + [ + 'admin_group' => 1, + ], + ); + + $row = Db::$db->fetch_assoc($request); + Db::$db->free_result($request); + + $this->assertNotEmpty($row, 'the forum has no administrator'); + + return (int) $row['id_member']; + } + + /** + * Registers a hook for the duration of the test. + * + * permanent: false keeps it in Config::$modSettings and out of the database, + * so tearDown() removes it by restoring that array. + * + * @param string $name The hook to add to, e.g. 'integrate_verify_user'. + * @param string $function The callable, in any form Utils::getCallable() takes. + */ + protected function hook(string $name, string $function): void + { + IntegrationHook::add($name, $function, false); + } + + /** + * Asserts the forum logged nothing since the test started. + * + * Most of what goes wrong in SMF is recorded here rather than shown, so a + * page that returned the right thing while quietly logging an undefined index + * has still regressed. + * + * @param string $message Optional context for the failure. + */ + protected function assertNoErrorsLogged(string $message = ''): void + { + $request = Db::$db->query( + 'SELECT error_type, message, file, line + FROM {db_prefix}log_errors + WHERE id_error > {int:watermark} + ORDER BY id_error', + [ + 'watermark' => $this->error_watermark, + ], + ); + + $this->assertNotFalse( + $request, + rtrim($message . "\n") . 'could not read the error log, so this proves nothing', + ); + + $errors = []; + + while ($row = Db::$db->fetch_assoc($request)) { + $errors[] = \sprintf( + ' [%s] %s (%s:%d)', + $row['error_type'], + html_entity_decode((string) $row['message'], ENT_QUOTES | ENT_HTML5, 'UTF-8'), + $row['file'], + $row['line'], + ); + } + + Db::$db->free_result($request); + + $this->assertSame( + [], + $errors, + rtrim($message . "\n") . 'the forum logged ' . \count($errors) . " error(s):\n" . implode("\n", $errors), + ); + } + + /** + * Runs a query and returns its first row. + * + * Exists because a failed query is not an exception here. MySQL returns + * false and carries on; PostgreSQL returns false and additionally puts the + * surrounding transaction into a failed state, so every later query in the + * same test returns false too. Handing that false to fetch_assoc() produces + * a TypeError about argument #1, which says nothing about what went wrong. + * + * @param string $sql The query, in SMF's dialect. + * @param array $params Its parameters. + * @return array|null The first row, or null when there were none. + */ + protected function queryRow(string $sql, array $params = []): ?array + { + $request = Db::$db->query($sql, $params); + + $this->assertNotFalse( + $request, + "the query failed:\n" . trim($sql) + . "\non PostgreSQL this also aborts the transaction, so every query after it fails too", + ); + + $row = Db::$db->fetch_assoc($request); + Db::$db->free_result($request); + + return \is_array($row) ? $row : null; + } + + /** + * Reads a setting straight out of the table. + * + * Bypasses Config::$modSettings and its cache, so what comes back is what + * the database actually holds rather than what the process believes. + * + * @param string $variable The setting to read. + * @return string|null The value, or null when there is no such row. + */ + protected function rawSetting(string $variable): ?string + { + $row = $this->queryRow( + 'SELECT value + FROM {db_prefix}settings + WHERE variable = {string:variable}', + [ + 'variable' => $variable, + ], + ); + + return $row === null ? null : (string) $row['value']; + } + + /** + * The highest id_error currently in the log. + * + * @return int The id, or 0 when nothing has ever been logged. + */ + private function lastErrorId(): int + { + $request = Db::$db->query( + 'SELECT COALESCE(MAX(id_error), 0) AS id_error + FROM {db_prefix}log_errors', + [], + ); + + if ($request === false) { + return 0; + } + + $row = Db::$db->fetch_assoc($request); + Db::$db->free_result($request); + + return (int) ($row['id_error'] ?? 0); + } +} diff --git a/tests/Integration/ModSettingsTest.php b/tests/Integration/ModSettingsTest.php new file mode 100644 index 00000000000..93a8312a962 --- /dev/null +++ b/tests/Integration/ModSettingsTest.php @@ -0,0 +1,123 @@ +<?php + +declare(strict_types=1); + +namespace SMF\Tests\Integration; + +use PHPUnit\Framework\Attributes\CoversMethod; +use SMF\Config; + +/** + * Config::updateModSettings() against a real database, on whichever engine is + * configured. + * + * The counter case is a regression test. Passing true or false as a value means + * "add one" or "take one away", and that was emitted as SET value = value + 1 + * against settings.value, which is a text column. MySQL coerces that silently; + * PostgreSQL rejects it outright, and because the PostgreSQL API discarded + * failed queries without a word, the statement simply did nothing. Every counter + * that goes through this path - totalMessages, totalTopics, totalMembers, + * unapprovedMembers - stayed frozen at whatever the last full recount produced. + * + * Nothing about that is reachable without a database, and it only shows up on + * one of the two engines, which is exactly the gap this suite exists to close. + */ +#[CoversMethod(Config::class, 'updateModSettings')] +class ModSettingsTest extends IntegrationTestCase +{ + /***************** + * Class constants + *****************/ + + private const COUNTER = 'smf_tests_counter'; + + /**************** + * Public methods + ****************/ + + public function testWritesAValue(): void + { + Config::updateModSettings([self::COUNTER => '41']); + + $this->assertSame('41', $this->rawSetting(self::COUNTER)); + $this->assertSame('41', Config::$modSettings[self::COUNTER]); + } + + public function testIncrementsACounter(): void + { + Config::updateModSettings([self::COUNTER => '41']); + Config::updateModSettings([self::COUNTER => true], true); + + $this->assertSame( + 42, + (int) $this->rawSetting(self::COUNTER), + 'the counter did not increment - on PostgreSQL this means the ' + . 'arithmetic was rejected and the failure swallowed', + ); + } + + public function testDecrementsACounter(): void + { + Config::updateModSettings([self::COUNTER => '41']); + Config::updateModSettings([self::COUNTER => false], true); + + $this->assertSame(40, (int) $this->rawSetting(self::COUNTER)); + } + + /** + * The value has to stay something the next increment can read back, so a + * cast that leaves '42.0000' behind is not good enough. + */ + public function testAnIncrementedCounterStaysAPlainInteger(): void + { + Config::updateModSettings([self::COUNTER => '41']); + Config::updateModSettings([self::COUNTER => true], true); + + $this->assertMatchesRegularExpression( + '~^\d+$~', + (string) $this->rawSetting(self::COUNTER), + 'the incremented value is not a plain integer, so it will not survive a round trip', + ); + } + + public function testCountersCanBeIncrementedRepeatedly(): void + { + // Starts at 10 rather than 0 on purpose: see the test below for why a + // brand new setting cannot be created holding a falsy value. + Config::updateModSettings([self::COUNTER => '10']); + + for ($i = 0; $i < 3; $i++) { + Config::updateModSettings([self::COUNTER => true], true); + } + + $this->assertSame(13, (int) $this->rawSetting(self::COUNTER)); + } + + /** + * A setting that does not exist yet and would only be set to nothingness is + * skipped rather than written. That is deliberate, and it is a sharp edge: + * seeding a counter at zero looks like it worked and leaves no row, so the + * first increment then has nothing to increment. + */ + public function testDoesNotCreateANewSettingHoldingAFalsyValue(): void + { + Config::updateModSettings([self::COUNTER => '0']); + + $this->assertNull($this->rawSetting(self::COUNTER)); + + // An existing one can be set to zero perfectly well. + Config::updateModSettings([self::COUNTER => '7']); + Config::updateModSettings([self::COUNTER => '0']); + + $this->assertSame('0', $this->rawSetting(self::COUNTER)); + } + + public function testUpdatingSettingsLogsNoErrors(): void + { + Config::updateModSettings([self::COUNTER => '1']); + Config::updateModSettings([self::COUNTER => true], true); + Config::updateModSettings([self::COUNTER => false], true); + + $this->assertNoErrorsLogged('updating a counter should be silent'); + } +} diff --git a/tests/Integration/SchemaTest.php b/tests/Integration/SchemaTest.php new file mode 100644 index 00000000000..145975992b9 --- /dev/null +++ b/tests/Integration/SchemaTest.php @@ -0,0 +1,104 @@ +<?php + +declare(strict_types=1); + +namespace SMF\Tests\Integration; + +use PHPUnit\Framework\Attributes\CoversNothing; +use SMF\Db\DatabaseApi as Db; +use SMF\Db\Schema\Table; + +/** + * Compares the schema SMF declares in PHP against the one it actually built. + * + * The schema for 3.0 lives in Sources/Db/Schema/v3_0/ as 70-odd classes, and the + * installer creates the database from them. Nothing checks afterwards that the + * two still agree, and a query naming a column that is no longer there fails at + * runtime only - inside a background task, it retries forever and takes down + * unrelated page loads. + * + * Both engines get their own DDL out of the same declarations, so this is worth + * running twice. + */ +#[CoversNothing] +class SchemaTest extends IntegrationTestCase +{ + /**************** + * Public methods + ****************/ + + public function testTheSchemaDeclaresTables(): void + { + // Guards the two tests below: if getAll() ever returns nothing they + // would both pass by iterating over an empty list. + $this->assertNotEmpty(Table::getAll('v3_0'), 'the v3_0 schema declares no tables at all'); + } + + public function testEveryDeclaredTableExists(): void + { + $existing = array_map( + static fn($table): string => strtolower($table), + Db::$db->list_tables(), + ); + + $missing = []; + + foreach (Table::getAll('v3_0') as $table) { + if (!\in_array(strtolower(Db::$db->prefix . $table->name), $existing, true)) { + $missing[] = $table->name; + } + } + + $this->assertSame([], $missing, 'tables the schema declares but the database does not have'); + } + + public function testEveryDeclaredColumnExists(): void + { + $missing = []; + + foreach (Table::getAll('v3_0') as $table) { + $columns = array_map( + static fn($column): string => strtolower($column), + Db::$db->list_columns('{db_prefix}' . $table->name), + ); + + // A table that is missing entirely is the other test's business. + if ($columns === []) { + continue; + } + + foreach ($table->columns as $column) { + if (!\in_array(strtolower($column->name), $columns, true)) { + $missing[] = $table->name . '.' . $column->name; + } + } + } + + $this->assertSame([], $missing, 'columns the schema declares but the database does not have'); + } + + /** + * The reverse direction, which is the one that catches a migration that + * dropped a column in the schema but not in the database, or a table left + * behind by an older version. + */ + public function testTheDatabaseHasNoColumnsTheSchemaDoesNotDeclare(): void + { + $unexpected = []; + + foreach (Table::getAll('v3_0') as $table) { + $declared = array_map( + static fn($column): string => strtolower($column->name), + $table->columns, + ); + + foreach (Db::$db->list_columns('{db_prefix}' . $table->name) as $column) { + if (!\in_array(strtolower($column), $declared, true)) { + $unexpected[] = $table->name . '.' . $column; + } + } + } + + $this->assertSame([], $unexpected, 'columns the database has that the schema does not declare'); + } +} diff --git a/tests/Integration/index.php b/tests/Integration/index.php new file mode 100644 index 00000000000..2844a3b9e7b --- /dev/null +++ b/tests/Integration/index.php @@ -0,0 +1,8 @@ +<?php + +// Try to handle it with the upper level index.php. (it should know what to do.) +if (file_exists(dirname(__DIR__) . DIRECTORY_SEPARATOR . 'index.php')) { + include dirname(__DIR__) . DIRECTORY_SEPARATOR . 'index.php'; +} else { + exit; +} diff --git a/tests/Support/HttpClient.php b/tests/Support/HttpClient.php new file mode 100644 index 00000000000..be674df3ca3 --- /dev/null +++ b/tests/Support/HttpClient.php @@ -0,0 +1,361 @@ +<?php + +declare(strict_types=1); + +namespace SMF\Tests\Support; + +use SMF\Config; + +/** + * A very small browser, for driving the forum over HTTP. + * + * Requests have to be real ones. Utils::obExit(), redirectexit(), + * serverResponse() and ErrorHandler::fatal*() all end in exit, and Db::$db, + * ActionTrait::$obj, Theme::$loaded and User::$loaded have no way to be reset, so + * a test process can carry out exactly one request in itself and no more. Going + * over the wire sidesteps all of that and exercises the same path a visitor does, + * including the session, the cookies and the theme. + * + * Uses curl through the extension the forum already requires, so it costs no new + * dependency. + */ +final class HttpClient +{ + /******************* + * Public properties + *******************/ + + /** + * @var string Where requests go. Public so a test can report it on failure. + */ + public readonly string $base_url; + + /********************* + * Internal properties + *********************/ + + /** + * @var \CurlHandle One handle for the life of the client. + * + * Reused rather than opened per request, and that is load bearing. curl only + * writes cookies that carry an expiry to the jar file; a session cookie has + * none, so closing the handle between requests threw the session away and + * every request arrived as a brand new visitor. The symptom is not an obvious + * one - pages still render, but any POST is rejected because the session + * check and the security token it carries were issued to a session that no + * longer exists. + */ + private \CurlHandle $handle; + + /** + * @var string Path to this client's cookie jar. + */ + private string $jar; + + /** + * @var HttpResponse|null The most recent response. + */ + private ?HttpResponse $last = null; + + /**************** + * Public methods + ****************/ + + /** + * @param string|null $base_url Override the forum URL to talk to. + */ + public function __construct(?string $base_url = null) + { + $this->base_url = rtrim($base_url ?? self::detectBaseUrl(), '/'); + + $this->jar = (string) tempnam(sys_get_temp_dir(), 'smf_tests_cookies_'); + + $handle = curl_init(); + + if (!$handle instanceof \CurlHandle) { + throw new \RuntimeException('could not start curl'); + } + + $this->handle = $handle; + } + + public function __destruct() + { + curl_close($this->handle); + + if ($this->jar !== '' && is_file($this->jar)) { + @unlink($this->jar); + } + } + + /** + * Fetches a page. + * + * @param string $path Either a full URL or something to hang off the board + * URL, with or without a leading slash. '?action=login' is typical. + * @return HttpResponse The response. + */ + public function get(string $path = ''): HttpResponse + { + return $this->request($this->url($path), null); + } + + /** + * Posts to a page. + * + * @param string $path Where to post, as for get(). + * @param array $fields The form fields. + * @return HttpResponse The response. + */ + public function post(string $path, array $fields): HttpResponse + { + return $this->request($this->url($path), $fields); + } + + /** + * Submits a form on a page the client has already fetched. + * + * This is the method to reach for. SMF forms carry a session check that + * User::checkSession() rejects the request without, and often a SecurityToken + * as well, both named unpredictably per session; resubmitting every field the + * page offered is what a browser does and saves the test knowing about any of + * it. + * + * @param HttpResponse $page The page holding the form. + * @param array $overrides Values to change or add. + * @param string $xpath Which form. Defaults to the first on the page. + * @return HttpResponse The response. + */ + public function submit(HttpResponse $page, array $overrides = [], string $xpath = '//form'): HttpResponse + { + return $this->request( + $this->url($page->formAction($xpath)), + array_merge($page->formFields($xpath), $overrides), + ); + } + + /** + * The most recent response, for reporting on a failure. + * + * @return HttpResponse|null The response, or null if nothing has been sent. + */ + public function lastResponse(): ?HttpResponse + { + return $this->last; + } + + /** + * Throws away this client's cookies, making it a fresh visitor. + */ + public function forgetCookies(): void + { + // In memory, not in the file: session cookies never reach the file. + curl_setopt($this->handle, CURLOPT_COOKIELIST, 'ALL'); + + if (is_file($this->jar)) { + file_put_contents($this->jar, ''); + } + } + + /****************** + * Internal methods + ******************/ + + /** + * Turns whatever a caller passed into an absolute URL. + * + * @param string $path A full URL, a query string, or a path. + * @return string An absolute URL. + */ + private function url(string $path): string + { + if ($path === '') { + return $this->base_url . '/'; + } + + // A form action is usually an absolute URL built from Config::$boardurl, + // which is not necessarily the host we are talking to - inside the + // container the forum answers on port 80 while boardurl names 8080. Keep + // the path and query, drop the rest. + if (preg_match('~^https?://~i', $path)) { + $parts = parse_url($path); + + $path = ($parts['path'] ?? '/') + . (isset($parts['query']) ? '?' . $parts['query'] : '') + . (isset($parts['fragment']) ? '#' . $parts['fragment'] : ''); + } + + if (str_starts_with($path, '?')) { + return $this->base_url . '/index.php' . $path; + } + + return $this->base_url . '/' . ltrim($path, '/'); + } + + /** + * Sends one request. + * + * @param string $url The absolute URL. + * @param array|null $fields POST fields, or null for a GET. + * @return HttpResponse The response. + */ + private function request(string $url, ?array $fields): HttpResponse + { + $handle = $this->handle; + + $options = [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HEADER => true, + // Off on purpose. A redirect is frequently the thing under test - + // posting a reply is a success only if it sends you somewhere - and + // following it silently would hide both the status and the location. + CURLOPT_FOLLOWLOCATION => false, + CURLOPT_COOKIEJAR => $this->jar, + CURLOPT_COOKIEFILE => $this->jar, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_TIMEOUT => 30, + CURLOPT_USERAGENT => 'SMF test suite', + ]; + + if ($fields !== null) { + $options[CURLOPT_POST] = true; + $options[CURLOPT_POSTFIELDS] = http_build_query($fields); + } else { + // The handle is reused, so a GET after a POST has to say so or it + // would repeat the previous body. + $options[CURLOPT_HTTPGET] = true; + } + + curl_setopt_array($handle, $options); + + $raw = curl_exec($handle); + + if ($raw === false) { + throw new \RuntimeException('request to ' . $url . ' failed: ' . curl_error($handle)); + } + + $status = (int) curl_getinfo($handle, CURLINFO_HTTP_CODE); + $header_size = (int) curl_getinfo($handle, CURLINFO_HEADER_SIZE); + + $raw = (string) $raw; + + [$headers, $set_cookies] = self::parseHeaders(substr($raw, 0, $header_size)); + + return $this->last = new HttpResponse( + $status, + substr($raw, $header_size), + $headers, + $url, + $set_cookies, + ); + } + + /************************* + * Internal static methods + *************************/ + + /** + * Works out which URL the forum answers on. + * + * SMF_TESTS_BASE_URL wins. Otherwise it is Config::$boardurl, unless nothing + * is listening there - which is the normal case when the tests run inside the + * web container, where the forum is on port 80 and boardurl names whatever + * port the host publishes. + * + * @return string The base URL. + */ + private static function detectBaseUrl(): string + { + $override = (string) getenv('SMF_TESTS_BASE_URL'); + + if ($override !== '') { + return $override; + } + + $boardurl = (string) (Config::$boardurl ?? ''); + + if ($boardurl !== '' && self::listening($boardurl)) { + return $boardurl; + } + + $parts = parse_url($boardurl) ?: []; + + return ($parts['scheme'] ?? 'http') . '://localhost' . ($parts['path'] ?? ''); + } + + /** + * Whether anything answers on the host and port of a URL. + * + * @param string $url The URL to try. + * @return bool Whether a connection could be opened. + */ + private static function listening(string $url): bool + { + $parts = parse_url($url); + + if (!isset($parts['host'])) { + return false; + } + + $port = $parts['port'] ?? (($parts['scheme'] ?? 'http') === 'https' ? 443 : 80); + + $socket = @fsockopen($parts['host'], (int) $port, $errno, $errstr, 2); + + if ($socket === false) { + return false; + } + + fclose($socket); + + return true; + } + + /** + * Splits a raw header block into name => value. + * + * Only the last set is kept, which matters because CURLOPT_HEADER includes + * every hop when a proxy or a 100-continue is involved. + * + * @param string $raw The raw headers. + * @return array Two items: the headers as lowercased name => value, and + * every Set-Cookie value in order. + */ + private static function parseHeaders(string $raw): array + { + $headers = []; + $cookies = []; + + foreach (preg_split('~\R~', $raw) ?: [] as $line) { + $line = trim($line); + + if ($line === '') { + continue; + } + + if (stripos($line, 'HTTP/') === 0) { + $headers = []; + $cookies = []; + + continue; + } + + $colon = strpos($line, ':'); + + if ($colon === false) { + continue; + } + + $name = strtolower(substr($line, 0, $colon)); + $value = trim(substr($line, $colon + 1)); + + $headers[$name] = $value; + + if ($name === 'set-cookie') { + $cookies[] = $value; + } + } + + return [$headers, $cookies]; + } +} diff --git a/tests/Support/HttpResponse.php b/tests/Support/HttpResponse.php new file mode 100644 index 00000000000..15b844577e6 --- /dev/null +++ b/tests/Support/HttpResponse.php @@ -0,0 +1,259 @@ +<?php + +declare(strict_types=1); + +namespace SMF\Tests\Support; + +/** + * One response from HttpClient. + * + * Parsing is deliberately DOM based rather than string matching. The theme moves + * around a lot, so a test that greps for a phrase fails the next time somebody + * reflows the markup, which teaches everyone to ignore it. + */ +final class HttpResponse +{ + /********************* + * Internal properties + *********************/ + + /** + * @var \DOMDocument|null The parsed body, once something has asked for it. + */ + private ?\DOMDocument $dom = null; + + /**************** + * Public methods + ****************/ + + /** + * @param int $status The HTTP status. + * @param string $body The response body. + * @param array $headers Headers, lowercased name => value. Where a header + * appeared more than once only the last is here; Set-Cookie is the one + * that routinely does, so it has its own list below. + * @param string $url The URL that was requested. + * @param array $set_cookies Every Set-Cookie header, in the order sent. + * Logging in sends two - the session and the forum's own - and keeping + * only one of them loses whichever the test cares about. + */ + public function __construct( + public readonly int $status, + public readonly string $body, + public readonly array $headers, + public readonly string $url, + public readonly array $set_cookies = [], + ) {} + + /** + * The response body as a DOM document. + * + * SMF emits HTML5, which DOMDocument grumbles about; the warnings are not + * interesting and would fail the test under failOnWarning, so they are + * collected and discarded rather than raised. + * + * Parsed once per response. Note the cache has to be a property: a static + * inside this method is shared by every instance of the class, so the first + * page fetched would be handed back for every page after it. + * + * @return \DOMDocument The parsed body. + */ + public function dom(): \DOMDocument + { + if ($this->dom instanceof \DOMDocument) { + return $this->dom; + } + + $dom = new \DOMDocument(); + + $previous = libxml_use_internal_errors(true); + $dom->loadHTML('<?xml encoding="UTF-8">' . $this->body, LIBXML_NOWARNING | LIBXML_NOERROR); + libxml_clear_errors(); + libxml_use_internal_errors($previous); + + return $this->dom = $dom; + } + + /** + * Runs an XPath query against the body. + * + * @param string $expression The XPath expression. + * @return \DOMNodeList The matching nodes. + */ + public function xpath(string $expression): \DOMNodeList + { + $result = (new \DOMXPath($this->dom()))->query($expression); + + return $result === false ? new \DOMNodeList() : $result; + } + + /** + * The page title, without the forum name SMF appends to it. + * + * @return string The title, or an empty string when there is none. + */ + public function title(): string + { + $titles = $this->xpath('//title'); + + return $titles->length === 0 ? '' : trim((string) $titles->item(0)?->textContent); + } + + /** + * All visible text, with runs of whitespace collapsed. + * + * For assertions where the structure genuinely does not matter, such as + * checking an error message reached the page at all. + * + * @return string The text content of the body. + */ + public function text(): string + { + $body = $this->xpath('//body'); + $text = $body->length === 0 ? $this->body : (string) $body->item(0)?->textContent; + + return trim((string) preg_replace('~\s+~u', ' ', $text)); + } + + /** + * Whatever the page is complaining about. + * + * SMF renders a fatal error as an ordinary page with a box on it, and the + * surrounding menus and news run to several hundred characters, so quoting + * the start of the body in a failure message reliably shows everything + * except the reason. This picks out the reason. + * + * @return string The error text, or an empty string when there is none. + */ + public function errorText(): string + { + $found = []; + + foreach ($this->xpath('//*[contains(@class, "errorbox")] | //*[contains(@class, "error_message")] | //*[@id="fatal_error"]') as $node) { + $text = trim((string) preg_replace('~\s+~u', ' ', $node->textContent)); + + if ($text !== '') { + $found[] = $text; + } + } + + return implode(' / ', array_unique($found)); + } + + /** + * Every field a browser would submit for the given form. + * + * SMF puts more than one hidden field in its forms - the session check that + * User::checkSession() insists on, and often a SecurityToken as well - and + * the names of both are generated per session. Collecting them all is both + * simpler and more honest than knowing which is which. + * + * Buttons are left out, because a browser submits only the one that was + * clicked and SMF branches on which that was. The posting form offers both + * "preview" and "post"; sending the pair means the preview wins and the reply + * is silently never made, with a perfectly good 200 to show for it. Pass the + * button you mean to press as an override. + * + * @param string $xpath Which form. Defaults to the first one on the page. + * @return array The fields, name => value. + */ + public function formFields(string $xpath = '//form'): array + { + $form = $this->xpath($xpath)->item(0); + + if (!$form instanceof \DOMElement) { + return []; + } + + $fields = []; + $finder = new \DOMXPath($this->dom()); + + foreach ($finder->query('.//input | .//textarea | .//select', $form) ?: [] as $input) { + if (!$input instanceof \DOMElement) { + continue; + } + + $name = $input->getAttribute('name'); + + if ($name === '') { + continue; + } + + $type = strtolower($input->getAttribute('type')); + + // A browser only submits these when they are ticked, and submitting + // an unticked one turns every checkbox on the page into a yes. + if (\in_array($type, ['checkbox', 'radio'], true) && !$input->hasAttribute('checked')) { + continue; + } + + // Only the button that was clicked gets submitted. See above. + if (\in_array($type, ['submit', 'button', 'reset', 'image'], true)) { + continue; + } + + $fields[$name] = match ($input->nodeName) { + 'textarea' => $input->textContent, + 'select' => $this->selectedOption($finder, $input), + default => $input->getAttribute('value'), + }; + } + + return $fields; + } + + /** + * Where the given form posts to. + * + * @param string $xpath Which form. Defaults to the first one on the page. + * @return string The action attribute, or the current URL when it has none. + */ + public function formAction(string $xpath = '//form'): string + { + $form = $this->xpath($xpath)->item(0); + + if (!$form instanceof \DOMElement) { + return $this->url; + } + + $action = $form->getAttribute('action'); + + return $action === '' ? $this->url : $action; + } + + /****************** + * Internal methods + ******************/ + + /** + * The value a browser would submit for a select element. + * + * @param \DOMXPath $finder An XPath instance for this document. + * @param \DOMElement $select The select element. + * @return string The selected value, or the first option's. + */ + private function selectedOption(\DOMXPath $finder, \DOMElement $select): string + { + $options = $finder->query('.//option', $select) ?: new \DOMNodeList(); + + $first = ''; + + foreach ($options as $option) { + if (!$option instanceof \DOMElement) { + continue; + } + + $value = $option->hasAttribute('value') ? $option->getAttribute('value') : $option->textContent; + + if ($first === '') { + $first = $value; + } + + if ($option->hasAttribute('selected')) { + return $value; + } + } + + return $first; + } +} diff --git a/tests/Support/index.php b/tests/Support/index.php new file mode 100644 index 00000000000..2844a3b9e7b --- /dev/null +++ b/tests/Support/index.php @@ -0,0 +1,8 @@ +<?php + +// Try to handle it with the upper level index.php. (it should know what to do.) +if (file_exists(dirname(__DIR__) . DIRECTORY_SEPARATOR . 'index.php')) { + include dirname(__DIR__) . DIRECTORY_SEPARATOR . 'index.php'; +} else { + exit; +} diff --git a/tests/Unit/ActionTraitTest.php b/tests/Unit/ActionTraitTest.php new file mode 100644 index 00000000000..c194babe400 --- /dev/null +++ b/tests/Unit/ActionTraitTest.php @@ -0,0 +1,66 @@ +<?php + +declare(strict_types=1); + +namespace SMF\Tests\Unit; + +use PHPUnit\Framework\Attributes\CoversTrait; +use PHPUnit\Framework\TestCase; +use SMF\Actions\Agreement; +use SMF\Actions\AgreementAccept; +use SMF\Actions\Login; +use SMF\Actions\Login2; +use SMF\Actions\Logout; +use SMF\Actions\Unread; +use SMF\Actions\UnreadReplies; +use SMF\ActionTrait; + +#[CoversTrait(ActionTrait::class)] +class ActionTraitTest extends TestCase +{ + /**************** + * Public methods + ****************/ + + public function testLoadReturnsAnInstanceOfTheClassItWasCalledOn(): void + { + $this->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 00000000000..8bdf1a515ad --- /dev/null +++ b/tests/Unit/CreatePostNotifyTest.php @@ -0,0 +1,80 @@ +<?php + +declare(strict_types=1); + +namespace SMF\Tests\Unit; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use SMF\Config; +use SMF\Tasks\CreatePost_Notify; + +#[CoversClass(CreatePost_Notify::class)] +class CreatePostNotifyTest extends TestCase +{ + /**************** + * Public methods + ****************/ + + public function testTheOffsetIsRelativeToTheForumTimezone(): void + { + Config::$modSettings['default_timezone'] = 'Etc/GMT-2'; + + $this->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<string, array{string, float}> + */ + 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 00000000000..0efe911f33d --- /dev/null +++ b/tests/Unit/IPTest.php @@ -0,0 +1,93 @@ +<?php + +declare(strict_types=1); + +namespace SMF\Tests\Unit; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use SMF\IP; + +#[CoversClass(IP::class)] +class IPTest extends TestCase +{ + /**************** + * Public methods + ****************/ + + public function testItNormalisesIPv6ToItsShortestForm(): void + { + $this->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<string, array{string, bool}> + */ + public static function validityProvider(): array + { + return [ + 'ipv4' => ['192.168.0.1', true], + 'ipv4 broadcast' => ['255.255.255.255', true], + 'ipv6' => ['2001:db8::1', true], + 'ipv6 loopback' => ['::1', true], + 'octet out of range' => ['256.1.1.1', false], + 'too few octets' => ['1.2.3', false], + 'empty' => ['', false], + // Any 4 or 16 byte string is read as a packed address instead, so a + // rubbish value only fails validation at some other length. See + // testAnyFourByteStringIsReadAsAPackedAddress(). + 'words' => ['not an ip address at all', false], + ]; + } +} diff --git a/tests/Unit/PunycodeTest.php b/tests/Unit/PunycodeTest.php new file mode 100644 index 00000000000..f2a44376423 --- /dev/null +++ b/tests/Unit/PunycodeTest.php @@ -0,0 +1,47 @@ +<?php + +declare(strict_types=1); + +namespace SMF\Tests\Unit; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use SMF\Punycode; + +#[CoversClass(Punycode::class)] +class PunycodeTest extends TestCase +{ + /**************** + * Public methods + ****************/ + + public function testAsciiDomainsPassThroughUnchanged(): void + { + $this->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<string, array{string, string}> + */ + public static function domainProvider(): array + { + return [ + 'german umlaut' => ['münchen.de', 'xn--mnchen-3ya.de'], + 'multiple labels' => ['münchen.beispiel.de', 'xn--mnchen-3ya.beispiel.de'], + ]; + } +} diff --git a/tests/Unit/SapiTest.php b/tests/Unit/SapiTest.php new file mode 100644 index 00000000000..8e5d3875feb --- /dev/null +++ b/tests/Unit/SapiTest.php @@ -0,0 +1,77 @@ +<?php + +declare(strict_types=1); + +namespace SMF\Tests\Unit; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use SMF\Sapi; + +#[CoversClass(Sapi::class)] +class SapiTest extends TestCase +{ + /**************** + * Public methods + ****************/ + + public function testCanonicalPathResolvesDotSegments(): void + { + $this->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<string, array{string, int}> + */ + 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 00000000000..a1b8d401c0b --- /dev/null +++ b/tests/Unit/SecurityTest.php @@ -0,0 +1,77 @@ +<?php + +declare(strict_types=1); + +namespace SMF\Tests\Unit; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use SMF\Security; + +#[CoversClass(Security::class)] +class SecurityTest extends TestCase +{ + /***************** + * Class constants + *****************/ + + /** + * bcrypt's cheapest cost. These tests care about behaviour, not about how + * long the hash takes. + */ + private const COST = 4; + + /**************** + * Public methods + ****************/ + + public function testAHashVerifiesAgainstItsOwnPassword(): void + { + $hash = Security::hashPassword('correct horse battery staple', self::COST); + + $this->assertTrue(Security::hashVerifyPassword('correct horse battery staple', $hash)); + } + + public function testAHashDoesNotVerifyAgainstAnythingElse(): void + { + $hash = Security::hashPassword('correct horse battery staple', self::COST); + + $this->assertFalse(Security::hashVerifyPassword('Correct horse battery staple', $hash)); + $this->assertFalse(Security::hashVerifyPassword('', $hash)); + } + + public function testHashingIsSaltedSoTheSamePasswordHashesDifferently(): void + { + $this->assertNotSame( + Security::hashPassword('same', self::COST), + Security::hashPassword('same', self::COST), + ); + } + + public function testHashesAreBcrypt(): void + { + $this->assertStringStartsWith('$2y$', Security::hashPassword('x', self::COST)); + } + + public function testTheCostFactorIsHonoured(): void + { + $this->assertStringStartsWith('$2y$04$', Security::hashPassword('x', 4)); + $this->assertStringStartsWith('$2y$05$', Security::hashPassword('x', 5)); + } + + public function testGeneratedPasswordsAreDistinctAndNonTrivial(): void + { + $first = Security::generatePassword(); + + $this->assertSame(20, \strlen($first)); + $this->assertNotSame($first, Security::generatePassword()); + } + + public function testGeneratedValidationCodesAreDistinctAndNonTrivial(): void + { + $first = Security::generateValidationCode(); + + $this->assertSame(10, \strlen($first)); + $this->assertNotSame($first, Security::generateValidationCode()); + } +} diff --git a/tests/Unit/TimeIntervalTest.php b/tests/Unit/TimeIntervalTest.php new file mode 100644 index 00000000000..cd31b20d4c2 --- /dev/null +++ b/tests/Unit/TimeIntervalTest.php @@ -0,0 +1,71 @@ +<?php + +declare(strict_types=1); + +namespace SMF\Tests\Unit; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use SMF\TimeInterval; + +#[CoversClass(TimeInterval::class)] +class TimeIntervalTest extends TestCase +{ + /**************** + * Public methods + ****************/ + + public function testItRoundTripsAnIso8601Duration(): void + { + $this->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. The leading 'P0D' comes along for the ride + // because this class, unlike \DateInterval, populates days for a + // duration that names no years or months. + $this->assertSame('P0DT30M', (string) new TimeInterval('PT30M')); + } + + public function testStringifyingIsStableAcrossARoundTrip(): void + { + $this->assertSame('P0DT30M', (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 00000000000..913cc10147c --- /dev/null +++ b/tests/Unit/UrlTest.php @@ -0,0 +1,128 @@ +<?php + +declare(strict_types=1); + +namespace SMF\Tests\Unit; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use SMF\Url; + +#[CoversClass(Url::class)] +class UrlTest extends TestCase +{ + /**************** + * Public methods + ****************/ + + public function testItExposesTheParsedComponents(): void + { + $url = new Url('https://user@a.example.com:8080/a/b?c=d#f'); + + $this->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<string, array{string, bool}> + */ + 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 00000000000..ffc139b707d --- /dev/null +++ b/tests/Unit/UtilsTest.php @@ -0,0 +1,184 @@ +<?php + +declare(strict_types=1); + +namespace SMF\Tests\Unit; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use SMF\Utils; + +#[CoversClass(Utils::class)] +class UtilsTest extends TestCase +{ + /**************** + * Public methods + ****************/ + + public function testBuildRegexSharesACommonPrefix(): void + { + $this->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<c>&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<string, array{string, int}> + */ + public static function entityLengthProvider(): array + { + return [ + 'empty' => ['', 0], + 'ascii' => ['abc', 3], + 'named entity' => ['&', 1], + 'numeric entity' => ['©', 1], + 'multibyte' => ["\u{00E9}\u{00E8}", 2], + 'mixed' => ['a&é', 3], + ]; + } +} diff --git a/tests/Unit/UuidTest.php b/tests/Unit/UuidTest.php new file mode 100644 index 00000000000..b09b7e65c70 --- /dev/null +++ b/tests/Unit/UuidTest.php @@ -0,0 +1,107 @@ +<?php + +declare(strict_types=1); + +namespace SMF\Tests\Unit; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use SMF\Uuid; + +#[CoversClass(Uuid::class)] +class UuidTest extends TestCase +{ + /***************** + * Class constants + *****************/ + + private const NIL = '00000000-0000-0000-0000-000000000000'; + + /**************** + * Public methods + ****************/ + + public function testGeneratedUuidsLookLikeUuids(): void + { + $this->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<string, array{int}> + */ + 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 00000000000..2844a3b9e7b --- /dev/null +++ b/tests/Unit/index.php @@ -0,0 +1,8 @@ +<?php + +// Try to handle it with the upper level index.php. (it should know what to do.) +if (file_exists(dirname(__DIR__) . DIRECTORY_SEPARATOR . 'index.php')) { + include dirname(__DIR__) . DIRECTORY_SEPARATOR . 'index.php'; +} else { + exit; +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 00000000000..d550a04349b --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,72 @@ +<?php + +declare(strict_types=1); + +/* + * Bootstrap for the unit test suite. + * + * Deliberately minimal: it defines the constants that index.php would define and + * points the autoloader at Sources/. It does not read Settings.php and it does not + * connect to a database, so anything reachable from here is reachable without a + * running forum. + * + * Code that needs Config::$modSettings, User::$me or Db::$db is out of scope for + * this suite and belongs in an integration suite running against a real install. + */ + +const TESTS_BOARDDIR = __DIR__ . '/..'; + +if (!defined('SMF')) { + define('SMF', 1); +} + +if (!defined('SMF_VERSION')) { + // Read from index.php so the tests never disagree with the release being built. + preg_match( + '~define\(\'SMF_VERSION\', \'([^\']+)\'\);~', + (string) file_get_contents(TESTS_BOARDDIR . '/index.php'), + $version, + ); + + preg_match( + '~define\(\'SMF_SOFTWARE_YEAR\', \'(\d{4})\'\);~', + (string) file_get_contents(TESTS_BOARDDIR . '/index.php'), + $year, + ); + + define('SMF_VERSION', $version[1] ?? '3.0'); + define('SMF_SOFTWARE_YEAR', $year[1] ?? date('Y')); +} + +define('SMF_FULL_VERSION', 'SMF ' . SMF_VERSION); +define('SMF_USER_AGENT', 'SMF test suite'); +define('JQUERY_VERSION', '3.6.3'); +define('FONTAWESOME_VERSION', '7.1.0'); +define('POSTGRE_TITLE', 'PostgreSQL'); +define('MYSQL_TITLE', 'MySQL'); +define('TIME_START', microtime(true)); +define('SMF_SETTINGS_FILE', TESTS_BOARDDIR . '/Settings.php'); +define('SMF_SETTINGS_BACKUP_FILE', TESTS_BOARDDIR . '/Settings_bak.php'); + +$loader = require TESTS_BOARDDIR . '/vendor/autoload.php'; +$loader->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 00000000000..2844a3b9e7b --- /dev/null +++ b/tests/index.php @@ -0,0 +1,8 @@ +<?php + +// Try to handle it with the upper level index.php. (it should know what to do.) +if (file_exists(dirname(__DIR__) . DIRECTORY_SEPARATOR . 'index.php')) { + include dirname(__DIR__) . DIRECTORY_SEPARATOR . 'index.php'; +} else { + exit; +}