Skip to content

[3.0][Testing] Add HTTP smoke tests that drive a running forum - #9347

Draft
albertlast wants to merge 41 commits into
SimpleMachines:release-3.0from
albertlast:tests/http
Draft

[3.0][Testing] Add HTTP smoke tests that drive a running forum#9347
albertlast wants to merge 41 commits into
SimpleMachines:release-3.0from
albertlast:tests/http

Conversation

@albertlast

@albertlast albertlast commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

Description

The integration suite from #9345 reaches the database, but never a page.
Everything between a request arriving and HTML coming back — the session, the
cookies, the theme, the templates, the permission checks — had no automated
coverage at all, and that is where the failures people actually report live.

tests/Integration/Http/ fixes that by driving a running forum over the wire.

.docker/test.sh                  # both engines, everything
.docker/test.sh --filter Posting

Requests have to be real ones: obExit(), redirectexit() and fatal*() all
end in exit, and Db::$db, ActionTrait::$obj and Theme::$loaded cannot be
reset, so a test process can carry out exactly one request in itself and no more.
tests/Support/HttpClient.php is a small browser built on the curl extension
the forum already requires
, so this costs no new dependency and no Node.

The tests
  • GuestPagesTest — a sweep of thirteen pages a visitor can reach, each
    asserting a real forum page came back (a fatal error in SMF is a normal page
    with an apology on it, so the status alone proves little) and that nothing was
    logged. Plus the RSS feed, a 404 for an unknown action, and registration.
  • LoginTest — signing in, the cookie being issued, a wrong password being
    refused, a post with no session check being refused, and signing out.
    Worth doing over HTTP precisely because User::setMe() skips all of it.
  • PostingTest — starting a topic and replying to it, then checking both are
    on the page and in the database; and that a guest cannot post.

Every one ends in assertNoErrorsLogged(). That is the point: SMF records most
of what goes wrong in log_errors rather than showing it, so a page can return a
flawless 200 while logging an undefined index on every hit.

Four things that made these harder than expected

Each is commented where it bites rather than worked around silently, because each
cost a debugging session and none of them are obvious from the symptom:

  1. The first request of a new session regenerates it. SMF sets a guest login
    cookie, and Cookie::setLoginCookie() throws the session away when that value
    changes — so a security token minted on the very first page a visitor sees can
    never be validated. It presents as a 403 "Token verification failed", which
    points at the token rather than at the session underneath it.
  2. Only the button that was clicked gets submitted. The posting form offers
    both preview and post; sending the pair means preview wins, the post is
    never made, and the response is a perfectly ordinary 200 with no topic behind
    it. formFields() therefore omits buttons and callers name the one they press.
  3. 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, which is the difference between a
    suite people trust and one that fails now and then for no reproducible reason.
  4. curl only writes cookies that carry an expiry to its jar file. A handle
    opened per request loses the session cookie every time, so every request
    arrives as a new visitor — pages still render, but every POST is rejected.
Transactions

IntegrationTestCase gains usesTransaction(), and the HTTP tests return false
from it. They have to: the request runs in the web server's process on its own
connection, so nothing here is visible to it and nothing it does can be rolled
back — and on MySQL's default REPEATABLE READ an open transaction here would keep
reading the snapshot it took before the request, quietly making
assertNoErrorsLogged() incapable of ever failing. PostingTest removes what it
creates through SMF's own Topic::remove() instead.

One installer fix

install-forum.sh now removes install.php when it finishes. The installer says
to do this and cannot do it itself — ?delete is a GET and command line arguments
only reach $_POST — and leaving it puts a "MAJOR SECURITY RISK: you have not
removed install.php" banner across the forum. The new tests found it.

Credentials

The signing-in tests need to know the administrator. They default to what
install-forum.sh creates and can be pointed elsewhere with SMF_ADMIN_USER and
SMF_ADMIN_PASS. A password the suite does not know makes them skip with a
message saying so
, rather than fail — that is a misconfigured forum, not a
regression. Verified both ways.

Verified
  • 151 tests, 336 assertions, green on both engines, from the tree in this
    branch, with each forum's own administrator password.
  • The flakiness is genuinely fixed: repeated full runs on PostgreSQL, and repeated
    PostingTest runs, all clean.
  • check-signed-off.php, check-smf-index.php and check-smf-license.php pass;
    php-cs-fixer is clean under PHP 8.5; shellcheck clean on every .docker script.

Merge order

Merge #9345 before this one, and the PRs it names before that. This branch contains
the whole chain, so the diff shown here is mostly theirs; once #9345 lands and this is
rebased on release-3.0, what is left is tests/Integration/Http/.

Issues References (Fixes|Related|Closes)

  1. Depends on [3.0][Testing] Add an integration test suite that runs against a real forum #9345 — extends IntegrationTestCase.
  2. Depends on [3.0][Testing] Install the forum from the command line #9344install-forum.sh provides the forum, and is fixed here.
  3. Related to [3.0][Testing] Add a Docker development environment for MySQL and PostgreSQL #9317.

albertlast and others added 30 commits July 28, 2026 23:02
Provides a reproducible local stack so contributors can work on SMF
without installing PHP, Composer or PostgreSQL on the host:

- PHP 8.4 on Apache, with every extension other/requirements.md lists as
  required (mbstring, fileinfo, pgsql, mysqli) or recommended (gd, intl,
  curl, exif, ftp, xsl, zip).
- PostgreSQL 17, with standard_conforming_strings forced on at database
  level as SMF requires.
- Mailpit, so mail() is captured locally and nothing can be sent out.
- Adminer, for browsing the database.

The entrypoint runs composer install, waits for the database, generates a
Settings.php pointed at the db service and drops install.php into place,
so a fresh checkout is ready to install on first boot.

Everything lives under .docker/ because check-smf-index.php and
check-smf-license.php skip dot directories, so the environment cannot
break the file integrity checks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
Install::finalize() called Time::strftime() to build the log_activity
date, and later Logging::updateStats(), which does the same thing. Both
end up in Time::__construct(), which reads User::$me to resolve the time
zone. But User::setMe()/User::loadMe() were not called until much later
in the same method, so installation died with:

  Error: Typed static property SMF\User::$me must not be accessed
  before initialization in Sources/Time.php:191

The installer therefore aborted on its last step, leaving the forum
without the member, topic and message stats that finalize() is
responsible for writing, including latestMember and latestRealName.

Moves the user initialisation up to just after the settings are
reloaded, which is the first point at which it can run, and leaves the
rest of the "we've just installed" block where it was.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
ActionTrait declares $obj as a static property, and a static property is
shared with every descendant class that does not redeclare it. None of
the eleven action classes that extend another action redeclare it, so
they all share one slot with their parent.

Once the parent has been loaded, load() finds that slot occupied and
returns the parent's instance, which does not satisfy the "static"
return type:

    SMF\Actions\Login2::load(): Return value must be of type
    SMF\Actions\Logout, SMF\Actions\Login2 returned

This is reachable during login: User::enforceBans() calls Logout::call()
to kick a banned member, by which point Login2 has already been loaded,
so a banned member gets a fatal error instead of being logged out.

Checks that the cached instance is of the class being loaded, rather than
merely present.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AGENTS.md is the cross-tool convention, read directly by Copilot, Codex,
Cursor, Gemini CLI and others. Claude Code reads only CLAUDE.md, so that
file imports AGENTS.md rather than duplicating it. A symlink would also
work, but not on Windows without Developer Mode, and .gitattributes
already forces LF for Windows checkouts, so the import is the portable
choice.

Contents are derived from the repository's own configuration rather than
from habit: .editorconfig, .gitattributes, .php-cs-fixer.dist.php and its
custom SectionComments fixer, composer.json, the workflows in .github,
DCO.txt, the PR template and compose.yaml.

Two points are worth stating explicitly for tools that cannot infer them:
that there is no test suite, so green CI only means the code parses and is
formatted; and that the sign-off check can pass on a pull request even
when the commits are unsigned, because it walks up to the parents.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SMF has no automated tests. CI proves that the code parses and that it is
formatted; it never executes anything. Every one of the bugs fixed in
SimpleMachines#9319 through SimpleMachines#9322 parsed cleanly and passed every check.

Quite a lot of 3.0 is reachable without a forum behind it. The bootstrap
here defines the constants index.php would define and points the
autoloader at Sources/, and that is enough: no Settings.php, no database,
no request. Anything that reaches Config::$modSettings, User::$me or
Db::$db is out of scope and belongs in an integration suite.

The first tests cover ground that recently broke:

- ActionTrait::load() returning an instance of the class it was called
  on, in both orders and in two separate class hierarchies.
- CreatePost_Notify::getTimeOffset(), including the half-hour and
  quarter-hour zones that an int cast used to truncate.
- Utils::buildRegex(), including the trailing quoted character from
  SimpleMachines#9318.

tests/ is already excluded from the license header check in BuildTools,
and the directory index.php files keep check-smf-index happy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds a PHPUnit workflow across the same 8.4 and 8.5 matrix the syntax
check already uses, and a composer test script.

Two adjustments fall out of running the suite next to the existing
checks. The PHPUnit cache lives in .phpunit.cache rather than under
cache/, because check-smf-index walks every directory that is not
hidden and would otherwise report a missing index file the moment
anyone runs the tests locally. And AGENTS.md no longer says there is no
test suite; it now says what the suite does and does not cover, so an
agent does not mistake a green run for proof that a change works.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first pass covered only the three things that had recently broken.
Quite a lot more is reachable without a database once the bootstrap sets
the paths and default language that the Unicode and entity helpers use to
find their data files, which is six lines and still reads nothing from
Settings.php.

Adds coverage for Utils' entity-aware string handling and Unicode case
conversion, IP, Url, Uuid, Sapi, Security's password hashing, Punycode
and TimeInterval. 99 tests, 144 assertions, on 8.4 and 8.5.

Two behaviours are deliberately described rather than asserted, because
pinning them down would preserve something that looks wrong:

- Sapi::memoryReturnBytes() strips the last character before parsing, so
  a unit-less value such as '128' reads as 12 and the '-1' that means "no
  limit" reads as 0. Only suffixed values are asserted.
- Url::isScheme() compares the scheme without normalising case, so an
  uppercase scheme fails to match its own name. Only exact-case matching
  is asserted.

IP's constructor accepts the packed binary form, which it cannot tell
apart from any other 4 or 16 byte string, so 'nope' becomes
110.111.112.101. That one is genuine ambiguity rather than a defect, so
it is pinned down as a test in its own right.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
memoryReturnBytes() removed the last character of the value before
parsing the number, on the assumption that it is always a designator.
PHP's shorthand notation is optional, so a plain byte count loses its
last digit: '128' reads as 12, and '2097152' reads as 209715.

Graphics\Image does exactly that, passing a computed byte count with no
designator, so resizing an image asks for a tenth of the memory it just
worked out that it needs.

The other value with no designator is '-1', which means there is no
limit. It read as 0, because intval('-') is 0, so setMemoryLimit() found
the current limit to be smaller than anything and set one. On a server
with no memory limit, asking for 128M capped it at 128M.

Only strips the last character when it is one of the designators PHP
accepts, and reports "no limit" as PHP_INT_MAX so that the callers
comparing it against an amount they need do not each have to special
case it.

The dead is_integer() check went with it; the parameter is typed string.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
RFC 3986, section 3.1, makes scheme names case insensitive, and this
class keeps the scheme exactly as it was written rather than normalizing
it. isScheme() compared the two with in_array(), so a URL written with an
uppercase scheme did not match its own name.

That reaches two callers. isWebsite() stops recognising HTTP:// and
HTTPS:// as websites, and the avatar handling in User treats a DATA: URI
as though it were a remote address.

Folds both sides before comparing, and makes the comparison strict while
it is there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The memoryReturnBytes() and isScheme() cases were described in comments
rather than asserted, because pinning down the behaviour would have
preserved it. Now that both are fixed, they become tests.

Verified to fail against the unfixed code: reverting the two source files
alone fails exactly these six tests and nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CoversClass on a trait is not a valid coverage target, and PHPUnit only
says so when coverage is actually collected. The suite passed on its own
and failed all five ActionTrait cases the moment anyone ran it with
--coverage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SMF supports MySQL and PostgreSQL, and until now this environment only
offered one of them. Both database services now start, and SMF_DB_TYPE
decides which one the generated Settings.php points at. It defaults to
mysql, since that is what the great majority of installs run on.

The two engines keep separate volumes, so a forum can be installed on
each and switched between by deleting Settings.php and restarting.
Settings.php wins over SMF_DB_TYPE once it exists, and the entrypoint
says so rather than silently ignoring the variable.

The postgres service is renamed from `db` to say what it is, and keeps
`db` as a network alias so Settings.php files written by the previous
version still resolve.

Engine settings are pinned the same way the postgres side already pinned
standard_conforming_strings: utf8mb4 and InnoDB, matching SMF's own table
DDL. The collation is deliberately left at the charset default, because
SMF sets CHARSET without COLLATE, and forcing one here would diverge from
the tables it creates.

Also corrects the everyday-use notes: php.ini, the vhost and the
entrypoint are copied into the image, so editing them needs a rebuild
rather than a restart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Docker environment gained a MySQL service and made it the default
engine, so the service list in AGENTS.md was describing a stack that no
longer matches: it named PostgreSQL alone, and pointed at a container
called smf-dev-db-1 that no longer exists now that the service is named
postgres. Lists both engines, and switches the examples to
docker compose exec, which addresses services by name and so does not
break again if the project or container naming changes.

Also states that SMF_DB_TYPE defaults to mysql, and how to re-test on the
other engine, because "any raw SQL must work on both" earlier in this
document is otherwise an instruction with no stated way to follow it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>

# Conflicts:
#	AGENTS.md
PostgreSQL logs every statement that errors together with the SQL that
caused it, with no configuration needed, and the log is only on the
container stderr. That makes `docker compose logs postgres` the most
useful debugging tool in the stack, and nothing said so.

MySQL logs server errors only, never the client statement that failed,
so the note points out the asymmetry: now that mysql is the default
engine, a suspected SQL problem is worth reproducing on postgres.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The testing notes described the suite mainly as a limitation, which left agents
with no way to tell whether the code in front of them was reachable from it. Sets
out the expectation that a reachable change carries a test, and lists the cases
that work with the examples already in tests/Unit/: pure helpers, value objects,
class-level behaviour, protected helpers through reflection, and modSettings keys
the test sets itself. Also names the strict-mode traps and the two ways the style
fixer rearranges a test file.

Corrects the CI claim as well; phpunit.yml only runs on pull requests and on
pushes to release-3.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
Maintenance::exit() renders the tool's templates, and those are the only
place errors are ever shown. On the command line it takes the fallthrough
path instead and goes straight to die(), so nothing was reported and the
exit status was always 0: a scripted install that died on step three
looked exactly like one that had finished.

ToolsBase::updateSettingsFile() made the same assumption more directly,
calling die() outright when Settings.php could not be written rather than
recording the error the way the web path does.

Writes the warnings and errors to stderr and exits non-zero when the tool
actually failed. A step that merely wants input it was not given sets
neither, so pausing part way through is still a success - the installer
is meant to be called more than once - and that case now says which step
it stopped on instead of nothing at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
Two things in the installer only hold when a browser is on the other end,
and both are reached before the forum exists, so neither could be worked
around from outside.

defaultHost() reads $_SERVER['SERVER_NAME'] and ['SERVER_PORT'] whenever
HTTP_HOST is absent. On the command line none of the three is set, so
every run began with an undefined index warning. Falls back to localhost:
the value only seeds the suggested board URL on the form, and a scripted
install passes its own boardurl in.

forumSettings() then built the same suggestion with
substr($self, 0, strrpos($self, '/')). getSelf() is $_SERVER['PHP_SELF'],
which in a request is a rooted path but on the command line is whatever
was typed - usually a bare 'install.php' with no directory in it. strrpos()
returns false, and substr() with a false length is fatal on PHP 8, so the
installer died here on every CLI run.

While in there: an unrecognised database type reported
Lang::getTxt('upgrade_unknown_error'), which is not a string that exists.
The fatal error was therefore blank in the browser too. Names the type
that was rejected and the ones that would have been accepted, which
matters most on the command line where the type is typed by hand rather
than picked from a list of exactly those keys.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
finalize() ends by signing the new administrator in, so the browser that
just ran the installer lands on an admin session instead of a login form.
It sets a login cookie, then records the session against the user agent
that asked for it.

None of that has any meaning on the command line. There is no browser to
hold the cookie and no user agent to key the session on, so every CLI
install ended with four warnings - headers sent after output had already
started, a session that could not be started, and an id that could not be
regenerated - and then wrote a sessions row built from an undefined
HTTP_USER_AGENT.

Runs the whole block only when there is a request behind it. The stats
that follow it are untouched, so an install still records latestMember,
totalMessages and totalTopics either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
Two things were wrong with the note the command line prints when a tool
stops part way. It indexed the step list to get the number, which counts
from zero, while every other line of output uses the step's own id, which
counts from one - so it disagreed with the "Step 3: Database Settings"
lines immediately above it.

It also fired on a successful run. Tools deliberately return false from
their last step so the web flow stops and renders its "all done" template,
which means reaching that step is success rather than a pause, and a
completed install claimed to have stopped at it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
The dev environment stopped at a Settings.php and a staged install.php,
leaving the actual install to a human clicking through a browser. That is
the one step between a fresh clone and a running forum that could not be
scripted, and everything that wants to test against a real install has to
start by doing it.

Adds four scripts under .docker/:

  install-forum.sh   installs a forum, no browser involved
  use-engine.sh      switches which installed forum is live
  reset.sh           empties one engine's database and restages
  lib.sh             shared settings and engine name normalisation

The installer is already CLI-native - parseCliArguments() turns
--name=value into $_POST and execute() runs every step in one process -
so this is two passes rather than 2.1's five curl requests. The second
pass carries pop_done, which is the short-circuit past the population
report; passing it on the first pass would skip building the schema.

--engine both installs MySQL and then PostgreSQL. It has to be sequential:
Settings.php pins a single db_type and Db::load() hands back the
connection it already made, so only one engine is ever live in a process.
Both installs are kept, and use-engine.sh swaps between them by putting
the saved Settings.php back - no restart, because the entrypoint only
writes one when there is not one already.

--pin-secrets fixes auth_secret and image_proxy_secret, which are
generated with random_bytes() and stored nowhere but Settings.php. Without
it the two installs differ by more than their database and a login cookie
does not survive the switch. The cookie name needs no such help:
createCookieName() is a crc32 of the database name and prefix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
The README invokes them as .docker/install-forum.sh rather than through
bash, which only works with the bit set. Windows checkouts do not carry
it, so it has to be recorded in the index.

lib.sh is left alone: it is sourced, never run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
albertlast and others added 3 commits August 2, 2026 08:18
The unit suite is deliberately database-free, and says so: its bootstrap
notes that anything reaching Config::$modSettings, User::$me or Db::$db
"belongs in an integration suite running against a real install". There
was not one, so most of the forum had no automated proof of anything.

Adds tests/Integration/ as a second PHPUnit testsuite, and .docker/test.sh
to run it on one engine or both. composer test still runs everything;
when there is no forum to talk to the integration tests skip rather than
fail, so it stays useful without Docker.

IntegrationTestCase gives each test a transaction that is rolled back
afterwards, actingAs()/adminId() via User::setMe(), hook() registration
that lives only in $modSettings, and assertNoErrorsLogged() - which is
usually the point of the test, because SMF records most of what goes
wrong in log_errors rather than showing it.

Three tests to start:

  HarnessTest    checks the harness itself, including that the rollback
                 really happens and that assertNoErrorsLogged can fail
  ModSettingsTest  the counter regression: updateModSettings($x, true)
                 emitted SET value = value + 1 against a text column
  SchemaTest     compares Sources/Db/Schema/v3_0/ against the database in
                 both directions, which is the drift AGENTS.md warns only
                 ever shows up at runtime

ModSettingsTest is why running both engines matters rather than being
tidy: with the fix reverted it still passes on MySQL, which coerces text
to a number, and fails only on PostgreSQL, which refuses. Verified in
both directions before committing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
The banners in the new test classes were written by hand with the wrong
number of asterisks, so SMF/section_comments did not recognise them and
inserted its own alongside, leaving IntegrationTestCase with two
"Internal properties" headings and two "Internal methods" ones.

AGENTS.md says not to hand-write these. Removes them and takes what the
fixer produces, along with the single_quote, native_function_invocation
and no_unused_imports changes it wanted in the same pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
c344b5c left a line holding nothing but three tabs, which
no_whitespace_in_blank_line rejects.

It has not turned CI red so far because the style workflow normally only
looks at the files a pull request changed. It checks everything when
composer.lock is part of the diff, which is how this surfaced, and it
will do the same to any other branch that touches a dependency.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
albertlast and others added 8 commits August 2, 2026 20:59
The installer tells you to delete it and cannot do it itself: the ?delete
link it offers is a GET, and command line arguments only ever reach
$_POST, so nothing on the CLI path ever gets there.

Leaving it behind is not cosmetic. Settings.php redirects every request
back into the installer while the file exists, so the forum the script
just built is unreachable, and SMF puts a "MAJOR SECURITY RISK: you have
not removed install.php" box on every page it shows an administrator -
which also lands in front of anything else a test or a person is trying
to read on that page.

Deleting it is safe for a reinstall because install_one() calls reset.sh
first, and reset.sh clears Settings.php and then blocks until the
entrypoint has staged a fresh copy. Adds a check in front of the two
installer passes to say so out loud when it has not: without one, php
reports "Could not open input file: install.php", which reads like a
broken script rather than a stack that was never made installable.

Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
The integration suite reaches the database, but not a page. Everything
between a request arriving and HTML coming back - the session, the
cookies, the theme, the templates, the permission checks - had no
automated coverage at all, and that is where the failures people actually
report live.

Requests have to be real ones: obExit(), redirectexit() and fatal*() all
end in exit, and Db::$db, ActionTrait::$obj and Theme::$loaded cannot be
reset, so a test process can carry out one request in itself and no more.
tests/Support/HttpClient.php is a small browser built on the curl
extension the forum already requires, so this costs no new dependency.

Three files to start: a sweep of the pages a guest can reach, the login
journey, and starting a topic and replying to it. Every one of them ends
in assertNoErrorsLogged(), which is the point - SMF records most of what
goes wrong in log_errors rather than showing it, so a page can return a
flawless 200 while logging an undefined index on every hit.

Four things about SMF made these harder to write than expected, and each
is commented where it bites rather than worked around silently:

  - The first request of a new session regenerates it, so a security
    token minted on the very first page a visitor sees can never be
    validated. It looks like a broken token, not a replaced session.
  - Only the button that was clicked gets submitted. The posting form
    offers "preview" and "post"; sending both means preview wins and the
    post is never made, with an ordinary 200 to show for it.
  - Security::spamProtection() allows one login or post every two seconds
    per IP, and tests are much faster than people, so submitForm() waits
    it out once instead of failing at random.
  - curl only writes cookies with an expiry to its jar file, so a handle
    opened per request loses the session every time.

HTTP tests cannot be wrapped in a transaction - the request runs in the
web server's process on its own connection, and on MySQL's REPEATABLE
READ an open transaction here would never see what it wrote, quietly
making assertNoErrorsLogged() incapable of failing. IntegrationTestCase
gains usesTransaction() so they can opt out, and PostingTest removes what
it creates through Topic::remove().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
The suite ships the machinery but not the instructions for using it, and
three things are not discoverable from reading it: which of the three
suites a new test belongs in, who the request is actually made as, and
where the endpoint and field names come from.

The second is the one that misleads. HttpTestCase inherits actingAs()
from IntegrationTestCase, where it repoints User::$me in the PHPUnit
process - but the request is handled by Apache in another process, which
knows only the cookie. Calling it in an HTTP test changes nothing and
leaves the assertions describing a guest, confidently. The identity of a
request here is the cookie jar and nothing else.

Field names are the opposite problem: they look like something to look
up, and are not. submit() scrapes the form the way a browser does, which
is what carries the session check and the security token - both named
differently for every session, so a hand built POST body gets a 403 it
cannot fix. The worked example prints them to make that concrete.

Also notes that install.php left in the board root puts an errorbox on
every page an administrator sees, which fails assertLooksLikeAForumPage()
and crowds out whatever the test was looking at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
Two forums side by side, each with its own administrator, and a password
chosen at install time is a combination that ends in hand written SQL
sooner or later - which is a poor way to answer a question as ordinary as
"is this the password?".

user.sh answers it. list shows the accounts, check says whether SMF would
accept a password and exits 0 or 1 so it can be used in a conditional,
and reset sets a new one. --engine reads the settings use-engine.sh saved
for the other engine, so the forum that is not currently live can be
looked at without switching to it and back.

Two details that stop it being a thin wrapper around an UPDATE:

  - The hashing goes through Security::hashPassword() rather than being
    written here, so what lands in the table is by construction what
    Login2 reads back out. A script that hashes passwords its own way is
    a script that eventually disagrees with the forum.
  - reset clears passwd_flood too. SMF locks an account out for a while
    after enough wrong guesses, and a new password behind a live lockout
    behaves exactly like a password that did not take.

check also points out an account that is not activated, which fails to
log in with an entirely correct password.

The password is passed to the container through the environment rather
than in the argument list, which anything able to read the process table
can see. Also completes the file list in the README, which still only
described the image and had none of the scripts in it.

Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
The tests skip rather than fail when they cannot sign in, which says what
is wrong but not what to do about it. user.sh answers both halves: check
says whether the password the suite is using is the right one, and reset
puts a forum installed some other way back on the credentials it expects.

Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant