Matomo, SuiteCRM, WordPress, MediaWiki, Joomla and Listmonk only realigned an existing admin login, so existing installs without platformadmin stopped the deploy. Each admin step now creates the account next to the old admin when it is missing and realigns it when it is an admin.
A platformadmin that exists without admin rights fails with its cause and remedy. MediaWiki alone promotes it, and only while SSO is the sole login and its confirmed email matches: there the account can only come from Keycloak's preferred_username. SuiteCRM keeps native login under SAML and lets users rename themselves, Listmonk's OIDC never creates that login, and Joomla's Keycloak plugin rewrites groups on every sign-in, so none of them promote.
Matomo, SuiteCRM and WordPress write through php://stdout and php://stderr: PHP 8.2 defines no STDOUT/STDERR for code fed on stdin. MediaWiki's account probe lives in files/sql/admin_state.sql and receives name and email as hex literals. A lint checks that each role's admin step creates a missing admin.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sites.php only created missing child sites and left an existing one
alone, so a network whose sites still sat on the legacy slug domain
<domain>.<network domain> (wp site create --slug), or carried a stale
home or siteurl, never converged.
- A site on the exact legacy slug domain and network path moves onto
its own domain and path. The run halts when a site already serves
that domain or when the legacy domain is itself a listed site.
- home and siteurl of every listed site are set to its URL, read raw
with the option_home/option_siteurl filters removed, and the run halts
when WordPress refuses the write.
- Every create, move or URL write prints CHANGED <url>, which the task
reports as changed; a second run prints nothing.
The legacy-slug move follows the same idea as dda51499a4 on the IAM
line, on main's WP_MS_SITE_URLS interface and with conflict checks.
SitesTest covers the move, the www network domain, the raw read, the
idempotent second run, unrelated sites and both halts; the harness
records moves under moved_sites because the script's own $moved shares
its global scope.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CI run 40979252521 WordPress#1 (multisite, multi network): a request on a network sibling resolved to the clearnet site, because WordPress picks the site by Host before any mu-plugin runs and the network kept only clearnet base URLs per site. Operator decision: a real multisite per network with real child domains (option B).
infinito-network-origins.php is now also installed as wp-content/sunrise.php (SUNRISE=true), so the sibling host resolves to its own site before mu-plugins load; a guard constant stops the second load from mu-plugins. WORDPRESS_SITE_DOMAINS holds the canonical domains in the first domain's network, WORDPRESS_NETWORK_BASE_URLS carries every sibling of each site's base URL (sites ';', siblings ','), and INFINITO_NETWORK_BASE_URLS takes that list. Child sites are inserted by files/php/multisite/sites.php only where no site exists on the domain and path, replacing wp site list plus wp site create. The multisite variant's child domains move from server.domains to domains.canonical.
Covered by 29 Python tests and two PHPUnit suites; PHPUnit and a variant 1 deploy with an onion pass still have to run (store-i18n gate and deploy).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
16 conflicts, resolved per hunk:
- sys-stk-front-proxy: tasks/main.yml keeps the network-sibling loop;
the single stack_host_contents write of http.conf plus vhost and the
front_proxy_vhost_changed gate move into tasks/vhost.yml, with the
http.conf decided per sibling domain so an onion vhost gets no HTTPS
redirect.
- sys-front-tls: 01_http.yml is gone as the updater intends; the
cert-renew include in main.yml gates on the vhost domain's TLS.
- sys-svc-compose: the updater's resolve_host_cid without the remote
echo and the prepared pending flag in up.yml, written with
IS_SWARM_MODE and the repeated-when block grouping.
- web-app-nextcloud: the updater's post-upgrade gate and pending-only
plugin configuration, which already carry the pinned-plugin handling.
- web-app-xwiki: one XWIKI_CORE_VERSION for the readiness probe and
style.css.j2; OIDC/LDAP follow xwiki_extensions_ready at bootstrap,
the S3 blob store stays off there as TestBlobstoreRenderOrder demands.
- roles_with_service: onion swap and dashboard card key combined, the
canonical domain is the canonical URL's host in both branches.
- sys-ctl-hlth-csp: --clearnet-timeout and --concurrency together;
run_checker forwards its options to build_docker_cmd, keeping the
script under the 500-line limit.
- web-app-matrix: Element branding and web_client_location side by side.
- web-app-stalwart/README.md: the generated Cosmos section is dropped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After an image update the YOURLS code was ahead of its schema (db_version 506 vs 507) and every short URL redirected to admin/upgrade.php until someone clicked Upgrade. install.yml now includes upgrade.yml, which runs files/php/upgrade_database.php as www-data inside the container: it boots YOURLS with YOURLS_UPGRADING, runs yourls_upgrade(1/3) when yourls_upgrade_is_needed(), prints 'upgraded <old> -> <new>' or 'current <version>', and turns YOURLS' die() exits into exit 1. A probe short URL must then answer 301/302 without pointing at admin/upgrade.php.
E2E against YOURLS 1.10.6 code with MariaDB: 506 -> 507 upgraded, second run 'current 507', duplicate-key failure exits 1 with the message. PHPUnit: 8 tests, 23 assertions.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
04_oidc.yml and 05_ldap_sync.yml looped over 69 auth_oidc and 73
auth_ldap settings and ran one `container exec … php cfg.php --set` per
setting; every exec booted Moodle once, 62 s plus 73 s per deploy on
echoserver.
tasks/utils/plugin_config.yml stages files/php/provision/plugin_config.php
in the container and runs it once per plugin with `{component, settings}`
as JSON on stdin. The script validates the whole payload before writing,
refuses `core` and settings forced in config.php, and for each setting
compares get_config with the wanted value and calls set_config plus
add_to_config_log only on a difference, as cfg.php does; set_config
invalidates the plugin's config cache itself (Moodle v5.3.0
moodlelib.php). It prints `CHANGED <n>`, which drives changed_when.
bind_pw and clientsecret no longer sit on the argv of every exec as
`--set="…"`; they only travel in the stdin payload. Values that the old
host-shell double quotes altered are now written exactly, so the first
run may report them changed.
Evidence: PHPUnit in the test container 42/42 including the 9 new
PluginConfigTest cases; Templar render of both mappings identical to the
old loop items (69/69, 73/73).
On merge: feature/iam-profile-rbac a46d93ff96 replaces several field_map
values with OIDC.ATTRIBUTES.*; carry them into the new mappings.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
provision_ai_gateway.php and provision_mcp.php shared a prefix inside one
directory, which test_prefix_grouped_files rejects. Both move into
files/php/provision/ as ai_gateway.php and mcp.php, the layout
web-app-n8n already uses for its Python provisioning scripts.
The two lookups that feed the scripts to the container follow, and the
unit test moves to its mirrored path as provision/AiGatewayTest.php.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Moodle 5 keeps AI provider settings in provider instances, not in the
plugin configuration. The deploy kept writing aiprovider_openai/* through
cfg.php and read the same value back, so its own check stayed green while
no provider existed. The gateway spec then found no settings page and
web-app-moodle#2 failed in run 37646050826.
The deploy now pipes files/php/provision_ai_gateway.php into the
container. It finds the instance by class and name or creates it through
core_ai\manager, points the three text actions at the chat endpoint,
switches image generation off, stores the virtual key and prints what
Moodle stored. The task asserts on that output. An instance an
administrator added is left alone.
The spec finds the instance by MOODLE_AI_PROVIDER_NAME on the providers
page and reads the endpoint from its generate_text action page.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Two lint suppressions shipped with the multisite OIDC work. Both are replaced
by the thing they were standing in for.
settings.php claimed an exemption from the mirrored-test rule because every
call it makes belongs to the WordPress runtime. The six functions stub in a
few lines, so the claim does not hold. SettingsTest.php declares WP_CLI and
the globals over a state holder and includes the real payload per case:
* a single site is written without switching blogs
* every site of a network is written, switch_to_blog paired with restore
* a site whose write does not persist is named by blog id
* every site is still visited before that failure is reported
* a payload that is not a map halts, and so does an absent one
The fourth case is the one worth having: a `break` on the first failure would
pass the other three and silently stop writing.
Neither existing house pattern fits. `eval` suits the tag-less tinker snippet
of web-app-akaunting, `proc_open` the standalone script of web-app-joomla;
this is a tagged file that needs a runtime, so it is stubs plus `include`,
which also avoids the `nocheck: eval` the akaunting test carries.
The installer test's cache-read exemption rested on a cached read serving a
stale call log. Measured, that risk is confined to the suite and sits around
3e-13: the log lives in a fresh TemporaryDirectory per run, mkdtemp verifies
non-existence, and each path is read once. What complying actually costs is
roughly 25 dead entries in an 8192-slot LRU, so the read goes through
utils.cache.files.read_text.
Proven by `make test-unit-php`: 32 tests, 68 assertions. The first version of
this test passed nothing at all -- it sits one directory deeper than the
other PHP tests, so dirname(__DIR__, 6) resolved to tests/ and the include
found no file. Without the test that would have stayed invisible.
The suite drives stubs, so it proves the payload's control flow and not that
WordPress behaves as stubbed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The install one-shot seeds the administrator from ADMIN_PASSWORD and runs only while
the first-run marker is absent, so after a credential rotation the account keeps the
secret the deployment no longer holds. The playwright trace of the run shows the
consequence: the native login never leaves /auth/login.
Akaunting ships no first-party command for this. user:seed only invokes a seeder and
sets no password, so the realignment goes through artisan tinker, which composer.json
lists under require rather than require-dev and is therefore present in the served
image. ADMIN_EMAIL and ADMIN_PASSWORD reach it through the environment, never through
an argument.
The password is assigned in clear text deliberately: App\Models\Auth\User defines
setPasswordAttribute, which bcrypts on save, so hashing before assignment would store
a hash of a hash. The fourth test case pins that down by asserting the raw value on
the model after save.
The role had no host/cid resolution at all -- setup/swarm.yml sidesteps it by scaling
to zero and running a one-shot service, which is too coarse for a per-run task -- so
it gains resolve_host_cid and addresses the container directly.
The snippet is not standalone PHP: it carries no opening tag because tinker evaluates
what it is given. The mirrored test therefore stubs App\Models\Auth\User and
Illuminate\Support\Facades\Hash and evaluates the file, covering the missing user, the
missing email, the already-matching password (no save) and the differing one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Bookwyrm has tests for the same trusted-header SSO bridge and settings
patcher; postmarks and baserow ship theirs untested, and the composer
require-dev stripper is a pure transformation nobody exercised.
Each test was checked by breaking its source: the admin gate failing
open, the group matcher loosened to a prefix, require-dev left in place
and the SSO gate forced open all turn the new tests red.
composer.json, phpunit.xml and the ruby runner were already in place and
had never run a single test, so 24 php and 20 ruby files shipped with
nothing but php -l and ruby -c behind them.
The two joomla scripts are pure file-in/file-out driven by env, which is
testable without Joomla: the tests drive the real script against a
temporary configuration.php. They pin what the code silently does today —
apostrophe escaping in the error level and in the db password, the two
repair regexes for malformed public lines, a missing key that is left
absent rather than added, and the asymmetry that toggle_debug exits 1 on a
missing file while configure_db exits 0.
The mastodon initializer is an env gate that downgrades URL building to
http. The test pins the security-relevant half: with OIDC off, with an
https issuer and with no issuer at all, SWD stays undefined. The http case
proves the branch is taken.
The three nocheck markers had to go with it: the mirrored-unit-test lint
fails a file that carries both a test and an exemption. package_plugin.php
keeps its marker, it is a build step.
vendor/ and .phpunit.result.cache were not ignored; composer install drops
25 packages into the tree.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>