Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 45 additions & 2 deletions packages/runtime-core/src/recipe-builders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,13 +351,56 @@ function blueprintWithWpConfigDefines(blueprint: unknown, defines: JsonObject):
}
}


/**
* Repair `site_admins` after Playground's `enableMultisite` step.
*
* The network install leaves `site_admins` as an empty string rather than the
* `array( $admin_login )` core's populate_network() normally writes —
* `admin_user_id` and `admin_email` come out correct, so the install only
* partially succeeds. An empty value is worse than a missing one: core falls
* back to a sane default only when the option is absent
* (`get_site_option( 'site_admins', array( 'admin' ) )`), so the empty string
* is returned instead and reaches `in_array()`:
*
* TypeError: in_array(): Argument #2 ($haystack) must be of type array,
* string given (wp-includes/capabilities.php)
*
* That fatals `is_super_admin()`, `grant_super_admin()` and every
* `manage_network_*` capability check, so any multisite suite touching network
* capabilities dies inside core rather than failing usefully.
*
* Idempotent and conservative: only writes when the current value is not a
* non-empty array, and derives the login from `admin_user_id` rather than
* assuming "admin".
*/
function multisiteSiteAdminsRepairPhp(): string {
return `
$admins = get_site_option('site_admins');
if (!is_array($admins) || $admins === array()) {
$login = null;
$admin_user_id = (int) get_site_option('admin_user_id');
if ($admin_user_id > 0) {
$user = get_userdata($admin_user_id);
if ($user && $user->user_login !== '') { $login = $user->user_login; }
}
if ($login === null) {
$first = get_users(array('number' => 1, 'orderby' => 'ID', 'order' => 'ASC', 'fields' => array('user_login')));
if (!empty($first)) { $login = $first[0]->user_login; }
}
if ($login !== null) { update_site_option('site_admins', array($login)); }
}`
}

function blueprintWithMultisite(blueprint: unknown, multisite: boolean): unknown {
if (!multisite) {
return blueprint
}

const multisiteSteps = [{ step: "enableMultisite" }, { step: "runPHP", code: multisiteSiteAdminsRepairPhp() }]

if (!isPlainObject(blueprint)) {
return { steps: [{ step: "enableMultisite" }] }
return { steps: multisiteSteps }
}

const existingSteps = Array.isArray(blueprint.steps) ? blueprint.steps : []
Expand All @@ -366,7 +409,7 @@ function blueprintWithMultisite(blueprint: unknown, multisite: boolean): unknown
}
return {
...blueprint,
steps: [{ step: "enableMultisite" }, ...existingSteps],
steps: [...multisiteSteps, ...existingSteps],
}
}

Expand Down
11 changes: 8 additions & 3 deletions tests/phpunit-project-autoload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -930,10 +930,15 @@ const multisiteRecipe = buildWordPressPhpunitRecipe({
multisite: true,
blueprint: { steps: [{ step: "setSiteOptions", options: { blogname: "Network tests" } }] },
})
assert.deepEqual((multisiteRecipe.runtime.blueprint as { steps: unknown[] }).steps, [
{ step: "enableMultisite" },
const multisiteSteps = (multisiteRecipe.runtime.blueprint as { steps: Array<{ step?: string; code?: string }> }).steps
assert.equal(multisiteSteps[0]?.step, "enableMultisite", "multisite PHPUnit recipes must boot Playground as multisite before running tests")
// enableMultisite leaves site_admins as an empty string, which fatals core's
// capability checks; the repair step must follow it before anything else runs.
assert.equal(multisiteSteps[1]?.step, "runPHP", "multisite recipes must repair site_admins immediately after enableMultisite")
assert.ok(multisiteSteps[1]?.code?.includes("site_admins"), "the step after enableMultisite must be the site_admins repair")
assert.deepEqual(multisiteSteps.slice(2), [
{ step: "setSiteOptions", options: { blogname: "Network tests" } },
], "multisite PHPUnit recipes must boot Playground as multisite before running tests")
], "caller-supplied blueprint steps must be preserved after the multisite bootstrap")
assert.equal(multisiteRecipe.runtime.preview?.siteUrl, "http://localhost", "multisite PHPUnit recipes need a canonical site URL without the dynamic Playground port")
assert.ok(multisiteRecipe.workflow.steps[0].args.includes("multisite=1"))

Expand Down
46 changes: 46 additions & 0 deletions tests/playground-multisite-site-admins.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import assert from "node:assert/strict"

import { buildWordPressPhpunitRecipe } from "../packages/runtime-core/src/recipe-builders.js"

/*
* Playground's enableMultisite step leaves site_admins as an empty string
* instead of array( $admin_login ). Because the option exists, core's
* get_site_option( 'site_admins', array( 'admin' ) ) default never applies, and
* the empty string reaches in_array() — fatalling is_super_admin(),
* grant_super_admin() and every manage_network_* check inside core.
*
* A multisite recipe must therefore carry a repair step, ordered after
* enableMultisite.
*/
const recipe = buildWordPressPhpunitRecipe({
pluginSlug: "example-plugin",
multisite: true,
mounts: [],
} as never) as { runtime?: { blueprint?: { steps?: Array<{ step?: string; code?: string }> } } }

const steps = recipe.runtime?.blueprint?.steps ?? []
const names = steps.map((step) => step.step)

assert.ok(names.includes("enableMultisite"), `expected enableMultisite, got ${JSON.stringify(names)}`)

const repairIndex = steps.findIndex(
(step) => step.step === "runPHP" && typeof step.code === "string" && step.code.includes("site_admins"),
)
assert.ok(repairIndex >= 0, `expected a runPHP site_admins repair step, got ${JSON.stringify(names)}`)
assert.ok(
names.indexOf("enableMultisite") < repairIndex,
"site_admins repair must run after enableMultisite",
)

const repairCode = steps[repairIndex]?.code ?? ""
assert.ok(repairCode.includes("is_array"), "repair must only write when the value is not already a valid array")
assert.ok(repairCode.includes("admin_user_id"), "repair should derive the login from admin_user_id, not assume 'admin'")

// Single-site recipes must not carry multisite steps at all.
const single = buildWordPressPhpunitRecipe({ pluginSlug: "example-plugin", multisite: false, mounts: [] } as never) as {
runtime?: { blueprint?: { steps?: Array<{ step?: string }> } }
}
const singleNames = (single.runtime?.blueprint?.steps ?? []).map((step) => step.step)
assert.ok(!singleNames.includes("enableMultisite"), "single-site recipe must not enable multisite")

console.log("multisite site_admins repair step present, ordered, and single-site unaffected")
Loading