From 61f85bf2d31bbe6038a2ec48e6bd593273bd63b5 Mon Sep 17 00:00:00 2001
From: Christopher Ross <122108986+thisismyurl@users.noreply.github.com>
Date: Mon, 22 Jun 2026 08:15:20 -0400
Subject: [PATCH 1/9] fix: wrap action link hrefs with esc_url() and link text
with esc_html__()
---
modules/calendar/calendar.php | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/modules/calendar/calendar.php b/modules/calendar/calendar.php
index fa4eab0b..11fdebcb 100644
--- a/modules/calendar/calendar.php
+++ b/modules/calendar/calendar.php
@@ -1201,20 +1201,20 @@ public function get_inner_information( $ef_calendar_item_information_fields, $po
$item_actions = array();
if ( $this->current_user_can_modify_post( $post ) ) {
// Edit this post.
- $item_actions['edit'] = '' . __( 'Edit', 'edit-flow' ) . '';
+ $item_actions['edit'] = '' . esc_html__( 'Edit', 'edit-flow' ) . '';
// Trash this post.
- $item_actions['trash'] = '' . __( 'Trash', 'edit-flow' ) . '';
+ $item_actions['trash'] = '' . esc_html__( 'Trash', 'edit-flow' ) . '';
// Preview/view this post.
if ( ! in_array( $post->post_status, $this->published_statuses ) ) {
/* translators: %s: post title */
- $item_actions['view'] = '' . __( 'Preview', 'edit-flow' ) . '';
+ $item_actions['view'] = '' . esc_html__( 'Preview', 'edit-flow' ) . '';
} elseif ( 'trash' != $post->post_status ) {
/* translators: %s: post title */
- $item_actions['view'] = '' . __( 'View', 'edit-flow' ) . '';
+ $item_actions['view'] = '' . esc_html__( 'View', 'edit-flow' ) . '';
}
// Save metadata.
/* translators: %s: post title */
- $item_actions['save hidden'] = '' . __( 'Save', 'edit-flow' ) . '';
+ $item_actions['save hidden'] = '' . esc_html__( 'Save', 'edit-flow' ) . '';
}
// Allow other plugins to add actions.
$item_actions = apply_filters( 'ef_calendar_item_actions', $item_actions, $post->ID );
From d9396ddc7c4ea4915300b0d12d4d631acf898596 Mon Sep 17 00:00:00 2001
From: Christopher Ross <122108986+thisismyurl@users.noreply.github.com>
Date: Mon, 22 Jun 2026 08:15:29 -0400
Subject: [PATCH 2/9] test: verify calendar action link hrefs pass through
esc_url()
---
tests/Integration/CalendarEscapingTest.php | 176 +++++++++++++++++++++
1 file changed, 176 insertions(+)
create mode 100644 tests/Integration/CalendarEscapingTest.php
diff --git a/tests/Integration/CalendarEscapingTest.php b/tests/Integration/CalendarEscapingTest.php
new file mode 100644
index 00000000..f0f8421e
--- /dev/null
+++ b/tests/Integration/CalendarEscapingTest.php
@@ -0,0 +1,176 @@
+user->create( array( 'role' => 'administrator' ) );
+ }
+
+ public static function wpTearDownAfterClass() {
+ self::delete_user( self::$admin_user_id );
+ }
+
+ protected function setUp(): void {
+ parent::setUp();
+ wp_set_current_user( self::$admin_user_id );
+ }
+
+ /**
+ * Edit and trash link hrefs must be wrapped in esc_url().
+ *
+ * A raw & in a URL breaks HTML attribute syntax; esc_url() converts it to
+ * &. We inject a URL with a bare & via a filter so the test fails on
+ * the pre-fix code (where esc_url() was absent) and passes after the fix.
+ *
+ * Delete-the-fix test: remove esc_url() from either href and this assertion
+ * fails because 'href="…&action=edit"' appears instead of '&action=edit'.
+ */
+ public function test_calendar_edit_link_href_is_url_escaped() {
+ global $edit_flow;
+
+ $post = self::factory()->post->create_and_get(
+ array(
+ 'post_status' => 'draft',
+ 'post_author' => self::$admin_user_id,
+ )
+ );
+
+ // Override the edit link to contain a bare & so we can detect whether
+ // esc_url() is applied (it would encode & as &).
+ add_filter(
+ 'get_edit_post_link',
+ static function () {
+ return 'http://example.com/wp-admin/post.php?post=1&action=edit';
+ }
+ );
+
+ ob_start();
+ $edit_flow->calendar->get_inner_information(
+ $edit_flow->calendar->get_post_information_fields( $post ),
+ $post
+ );
+ $html = ob_get_clean();
+
+ remove_all_filters( 'get_edit_post_link' );
+
+ $this->assertStringContainsString(
+ 'href="http://example.com/wp-admin/post.php?post=1&action=edit"',
+ $html,
+ 'Edit link href must pass through esc_url() — bare & is invalid in an HTML attribute.'
+ );
+ $this->assertStringNotContainsString(
+ 'href="http://example.com/wp-admin/post.php?post=1&action=edit"',
+ $html,
+ 'Unescaped & must not appear in the edit href.'
+ );
+ }
+
+ /**
+ * Trash link href must be wrapped in esc_url().
+ *
+ * Same pattern as the edit link: bare & in the URL should be encoded.
+ *
+ * Delete-the-fix test: remove esc_url() from the trash href and the
+ * first assertion fails.
+ */
+ public function test_calendar_trash_link_href_is_url_escaped() {
+ global $edit_flow;
+
+ $post = self::factory()->post->create_and_get(
+ array(
+ 'post_status' => 'draft',
+ 'post_author' => self::$admin_user_id,
+ )
+ );
+
+ add_filter(
+ 'post_delete_link',
+ static function () {
+ return 'http://example.com/wp-admin/post.php?post=1&action=trash&_wpnonce=abc';
+ }
+ );
+
+ ob_start();
+ $edit_flow->calendar->get_inner_information(
+ $edit_flow->calendar->get_post_information_fields( $post ),
+ $post
+ );
+ $html = ob_get_clean();
+
+ remove_all_filters( 'post_delete_link' );
+
+ // The trash link is built with get_delete_post_link(); confirm that
+ // the output contains a properly-formed href (no raw &).
+ $this->assertStringNotContainsString(
+ 'href="http://example.com/wp-admin/post.php?post=1&action=trash&_wpnonce=abc"',
+ $html,
+ 'Trash href must not contain unescaped &.'
+ );
+ }
+
+ /**
+ * Published-post view link href must be wrapped in esc_url().
+ *
+ * get_permalink() returns a plain URL (no HTML encoding). Without esc_url()
+ * a permalink containing & (e.g. from a query string) would produce invalid
+ * HTML. We use the post_link filter to inject such a URL.
+ *
+ * Delete-the-fix test: remove esc_url() from the view href and the first
+ * assertion fails because the bare & survives into the attribute.
+ */
+ public function test_calendar_view_link_href_is_url_escaped() {
+ global $edit_flow;
+
+ $post = self::factory()->post->create_and_get(
+ array(
+ 'post_status' => 'publish',
+ 'post_author' => self::$admin_user_id,
+ )
+ );
+
+ add_filter(
+ 'post_link',
+ static function () {
+ return 'http://example.com/?p=1&preview=true';
+ }
+ );
+
+ ob_start();
+ $edit_flow->calendar->get_inner_information(
+ $edit_flow->calendar->get_post_information_fields( $post ),
+ $post
+ );
+ $html = ob_get_clean();
+
+ remove_all_filters( 'post_link' );
+
+ $this->assertStringContainsString(
+ 'href="http://example.com/?p=1&preview=true"',
+ $html,
+ 'View link href must pass through esc_url() — bare & in permalink is invalid in an HTML attribute.'
+ );
+ $this->assertStringNotContainsString(
+ 'href="http://example.com/?p=1&preview=true"',
+ $html,
+ 'Unescaped & must not appear in the view href.'
+ );
+ }
+}
From 6c49387365284f73fed0cbf2c74615b52e7a0dac Mon Sep 17 00:00:00 2001
From: Christopher Ross <122108986+thisismyurl@users.noreply.github.com>
Date: Mon, 22 Jun 2026 08:27:21 -0400
Subject: [PATCH 3/9] test: fix assertion to use & (esc_url encoding) not
& (esc_html encoding)
---
tests/Integration/CalendarEscapingTest.php | 60 ++++++++++++----------
1 file changed, 33 insertions(+), 27 deletions(-)
diff --git a/tests/Integration/CalendarEscapingTest.php b/tests/Integration/CalendarEscapingTest.php
index f0f8421e..dad206ed 100644
--- a/tests/Integration/CalendarEscapingTest.php
+++ b/tests/Integration/CalendarEscapingTest.php
@@ -34,14 +34,16 @@ protected function setUp(): void {
}
/**
- * Edit and trash link hrefs must be wrapped in esc_url().
+ * Edit link href must be wrapped in esc_url().
*
- * A raw & in a URL breaks HTML attribute syntax; esc_url() converts it to
- * &. We inject a URL with a bare & via a filter so the test fails on
- * the pre-fix code (where esc_url() was absent) and passes after the fix.
+ * esc_url() encodes raw & as the numeric entity &. We inject a URL with
+ * a bare & via a filter so the assertion fails on the pre-fix code (where
+ * esc_url() was absent) and passes after the fix.
*
- * Delete-the-fix test: remove esc_url() from either href and this assertion
- * fails because 'href="…&action=edit"' appears instead of '&action=edit'.
+ * Delete-the-fix test: remove esc_url() from the edit href on line 1204 of
+ * modules/calendar/calendar.php and the assertStringContainsString below
+ * fails because the raw & survives into the output instead of being encoded
+ * as &.
*/
public function test_calendar_edit_link_href_is_url_escaped() {
global $edit_flow;
@@ -53,8 +55,8 @@ public function test_calendar_edit_link_href_is_url_escaped() {
)
);
- // Override the edit link to contain a bare & so we can detect whether
- // esc_url() is applied (it would encode & as &).
+ // Inject a URL with a bare & so we can assert esc_url() encoding.
+ // esc_url() converts & to & (numeric entity), not &.
add_filter(
'get_edit_post_link',
static function () {
@@ -72,9 +74,9 @@ static function () {
remove_all_filters( 'get_edit_post_link' );
$this->assertStringContainsString(
- 'href="http://example.com/wp-admin/post.php?post=1&action=edit"',
+ 'href="http://example.com/wp-admin/post.php?post=1&action=edit"',
$html,
- 'Edit link href must pass through esc_url() — bare & is invalid in an HTML attribute.'
+ 'Edit link href must pass through esc_url() — bare & becomes & in URL context.'
);
$this->assertStringNotContainsString(
'href="http://example.com/wp-admin/post.php?post=1&action=edit"',
@@ -86,10 +88,11 @@ static function () {
/**
* Trash link href must be wrapped in esc_url().
*
- * Same pattern as the edit link: bare & in the URL should be encoded.
+ * Same pattern as the edit link. get_delete_post_link() returns a raw URL;
+ * esc_url() must encode the & separators as &.
*
- * Delete-the-fix test: remove esc_url() from the trash href and the
- * first assertion fails.
+ * Delete-the-fix test: remove esc_url() from the trash href on line 1206
+ * and the assertStringContainsString below fails.
*/
public function test_calendar_trash_link_href_is_url_escaped() {
global $edit_flow;
@@ -102,7 +105,7 @@ public function test_calendar_trash_link_href_is_url_escaped() {
);
add_filter(
- 'post_delete_link',
+ 'delete_post_link',
static function () {
return 'http://example.com/wp-admin/post.php?post=1&action=trash&_wpnonce=abc';
}
@@ -115,26 +118,29 @@ static function () {
);
$html = ob_get_clean();
- remove_all_filters( 'post_delete_link' );
+ remove_all_filters( 'delete_post_link' );
- // The trash link is built with get_delete_post_link(); confirm that
- // the output contains a properly-formed href (no raw &).
- $this->assertStringNotContainsString(
- 'href="http://example.com/wp-admin/post.php?post=1&action=trash&_wpnonce=abc"',
+ // get_delete_post_link() wraps via wp_nonce_url which uses & itself,
+ // so the injected URL path is not directly reachable via a hook that
+ // overrides the full return value. We confirm instead that the output
+ // does not contain any raw & in an href attribute (would indicate
+ // esc_url() is absent).
+ $this->assertDoesNotMatchRegularExpression(
+ '~href="[^"]*&[^#038amp][^"]*"~',
$html,
- 'Trash href must not contain unescaped &.'
+ 'No href attribute in the calendar item should contain a raw & character.'
);
}
/**
* Published-post view link href must be wrapped in esc_url().
*
- * get_permalink() returns a plain URL (no HTML encoding). Without esc_url()
- * a permalink containing & (e.g. from a query string) would produce invalid
- * HTML. We use the post_link filter to inject such a URL.
+ * get_permalink() returns a plain URL with raw & separators. Without esc_url()
+ * a permalink containing & produces invalid HTML. The post_link filter lets us
+ * inject such a URL and verify it gets encoded as &.
*
- * Delete-the-fix test: remove esc_url() from the view href and the first
- * assertion fails because the bare & survives into the attribute.
+ * Delete-the-fix test: remove esc_url() from the view href on line 1213 of
+ * modules/calendar/calendar.php and the assertStringContainsString below fails.
*/
public function test_calendar_view_link_href_is_url_escaped() {
global $edit_flow;
@@ -163,9 +169,9 @@ static function () {
remove_all_filters( 'post_link' );
$this->assertStringContainsString(
- 'href="http://example.com/?p=1&preview=true"',
+ 'href="http://example.com/?p=1&preview=true"',
$html,
- 'View link href must pass through esc_url() — bare & in permalink is invalid in an HTML attribute.'
+ 'View link href must pass through esc_url() — bare & in permalink becomes &.'
);
$this->assertStringNotContainsString(
'href="http://example.com/?p=1&preview=true"',
From a7ac605cd787607c3c0d65f230acafad76e446c9 Mon Sep 17 00:00:00 2001
From: Christopher Ross <122108986+thisismyurl@users.noreply.github.com>
Date: Mon, 22 Jun 2026 08:28:36 -0400
Subject: [PATCH 4/9] chore: add Unreleased CHANGELOG entry for calendar
escaping fix
---
CHANGELOG.md | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 32a8115b..18f25d6d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [Unreleased] - TBD
+
+### Security
+
+* fix: escape calendar item action link hrefs with esc_url() and link text with esc_html__() by @thisismyurl
+
## [0.11.1] - 2026-08-18
A maintenance and compatibility release. It closes an information-disclosure issue in the Editorial Metadata module, corrects calendar date handling, declares compatibility with WordPress 7.1, and takes a batch of dependency updates including several security fixes. All users are encouraged to update.
From 9aeb11026933add61b6d56296149d40c93bd3b3b Mon Sep 17 00:00:00 2001
From: Christopher Ross <122108986+thisismyurl@users.noreply.github.com>
Date: Mon, 22 Jun 2026 12:42:23 +0000
Subject: [PATCH 5/9] test: drop untestable trash-link guard; keep edit + view
guards
---
tests/Integration/CalendarEscapingTest.php | 54 +++-------------------
1 file changed, 7 insertions(+), 47 deletions(-)
diff --git a/tests/Integration/CalendarEscapingTest.php b/tests/Integration/CalendarEscapingTest.php
index dad206ed..4001e0f7 100644
--- a/tests/Integration/CalendarEscapingTest.php
+++ b/tests/Integration/CalendarEscapingTest.php
@@ -7,6 +7,13 @@
* WordPress.Security.EscapeOutput — the same class of defect fixed in
* story-budget.php via PR #993.
*
+ * The trash link (get_delete_post_link()) is not independently testable at
+ * this integration layer: the function routes through wp_nonce_url() which
+ * already encodes & as & before esc_url() sees it. An integration test
+ * for that path cannot distinguish "esc_url() ran" from "wp_nonce_url()
+ * pre-encoded the value." The production fix is present in calendar.php;
+ * the edit and view links cover the pattern.
+ *
* @package Automattic\EditFlow\Tests\Integration
*/
@@ -85,53 +92,6 @@ static function () {
);
}
- /**
- * Trash link href must be wrapped in esc_url().
- *
- * Same pattern as the edit link. get_delete_post_link() returns a raw URL;
- * esc_url() must encode the & separators as &.
- *
- * Delete-the-fix test: remove esc_url() from the trash href on line 1206
- * and the assertStringContainsString below fails.
- */
- public function test_calendar_trash_link_href_is_url_escaped() {
- global $edit_flow;
-
- $post = self::factory()->post->create_and_get(
- array(
- 'post_status' => 'draft',
- 'post_author' => self::$admin_user_id,
- )
- );
-
- add_filter(
- 'delete_post_link',
- static function () {
- return 'http://example.com/wp-admin/post.php?post=1&action=trash&_wpnonce=abc';
- }
- );
-
- ob_start();
- $edit_flow->calendar->get_inner_information(
- $edit_flow->calendar->get_post_information_fields( $post ),
- $post
- );
- $html = ob_get_clean();
-
- remove_all_filters( 'delete_post_link' );
-
- // get_delete_post_link() wraps via wp_nonce_url which uses & itself,
- // so the injected URL path is not directly reachable via a hook that
- // overrides the full return value. We confirm instead that the output
- // does not contain any raw & in an href attribute (would indicate
- // esc_url() is absent).
- $this->assertDoesNotMatchRegularExpression(
- '~href="[^"]*&[^#038amp][^"]*"~',
- $html,
- 'No href attribute in the calendar item should contain a raw & character.'
- );
- }
-
/**
* Published-post view link href must be wrapped in esc_url().
*
From 39d07b46e0d38ec4e6c36a7720497f4c003d7b31 Mon Sep 17 00:00:00 2001
From: Christopher Ross <122108986+thisismyurl@users.noreply.github.com>
Date: Mon, 22 Jun 2026 12:47:20 +0000
Subject: [PATCH 6/9] =?UTF-8?q?chore:=20fix=20CHANGELOG=20format=20?=
=?UTF-8?q?=E2=80=94=20plain=20imperative,=20props=20convention?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
CHANGELOG.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 18f25d6d..56c90248 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,7 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
### Security
-* fix: escape calendar item action link hrefs with esc_url() and link text with esc_html__() by @thisismyurl
+* Escape calendar item action link hrefs with esc_url() and link text with esc_html__() (props @thisismyurl)
## [0.11.1] - 2026-08-18
From b2c2af688e314e59ce5bd84ddd49a508fb724e27 Mon Sep 17 00:00:00 2001
From: Christopher Ross <122108986+thisismyurl@users.noreply.github.com>
Date: Mon, 22 Jun 2026 13:48:44 +0000
Subject: [PATCH 7/9] fix: capitalize docblock long descriptions to satisfy
PHPCS rule
---
tests/Integration/CalendarEscapingTest.php | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/tests/Integration/CalendarEscapingTest.php b/tests/Integration/CalendarEscapingTest.php
index 4001e0f7..b8b62765 100644
--- a/tests/Integration/CalendarEscapingTest.php
+++ b/tests/Integration/CalendarEscapingTest.php
@@ -43,7 +43,7 @@ protected function setUp(): void {
/**
* Edit link href must be wrapped in esc_url().
*
- * esc_url() encodes raw & as the numeric entity &. We inject a URL with
+ * The esc_url() function encodes raw & as the numeric entity &. We inject a URL with
* a bare & via a filter so the assertion fails on the pre-fix code (where
* esc_url() was absent) and passes after the fix.
*
@@ -95,7 +95,7 @@ static function () {
/**
* Published-post view link href must be wrapped in esc_url().
*
- * get_permalink() returns a plain URL with raw & separators. Without esc_url()
+ * The get_permalink() function returns a plain URL with raw & separators. Without esc_url()
* a permalink containing & produces invalid HTML. The post_link filter lets us
* inject such a URL and verify it gets encoded as &.
*
From 55391531bd69f314a6c77ecd93dbd5dcdafd507b Mon Sep 17 00:00:00 2001
From: thisismyurl <122108986+thisismyurl@users.noreply.github.com>
Date: Mon, 13 Jul 2026 10:17:55 -0400
Subject: [PATCH 8/9] fix: escape module short-description hrefs in Calendar
and Story Budget registrations
Folds in the two remaining admin_url()-in-href spots flagged in review,
closing out the escaping sweep for this module surface.
Co-Authored-By: Claude Fable 5
---
CHANGELOG.md | 1 +
modules/calendar/calendar.php | 2 +-
modules/story-budget/story-budget.php | 2 +-
3 files changed, 3 insertions(+), 2 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 56c90248..e42557fd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
### Security
* Escape calendar item action link hrefs with esc_url() and link text with esc_html__() (props @thisismyurl)
+* Escape module short-description link hrefs in the Calendar and Story Budget registrations with esc_url() (props @thisismyurl)
## [0.11.1] - 2026-08-18
diff --git a/modules/calendar/calendar.php b/modules/calendar/calendar.php
index 11fdebcb..10b29541 100644
--- a/modules/calendar/calendar.php
+++ b/modules/calendar/calendar.php
@@ -107,7 +107,7 @@ public function __construct() {
$args = array(
'title' => __( 'Calendar', 'edit-flow' ),
/* translators: %s: URL to the calendar page */
- 'short_description' => sprintf( __( 'View upcoming content in a customizable calendar.', 'edit-flow' ), admin_url( 'index.php?page=calendar' ) ),
+ 'short_description' => sprintf( __( 'View upcoming content in a customizable calendar.', 'edit-flow' ), esc_url( admin_url( 'index.php?page=calendar' ) ) ),
'extended_description' => __( 'Edit Flow’s calendar lets you see your posts over a customizable date range. Filter by status or click on the post title to see its details. Drag and drop posts between days to change their publication date.', 'edit-flow' ),
'module_url' => $this->module_url,
'img_url' => $this->module_url . 'lib/calendar_s128.png',
diff --git a/modules/story-budget/story-budget.php b/modules/story-budget/story-budget.php
index af06748b..e188f4a7 100644
--- a/modules/story-budget/story-budget.php
+++ b/modules/story-budget/story-budget.php
@@ -102,7 +102,7 @@ public function __construct() {
$args = array(
'title' => __( 'Story Budget', 'edit-flow' ),
// translators: %s is a link to the story budget page.
- 'short_description' => sprintf( __( 'View the status of all your content at a glance.', 'edit-flow' ), admin_url( 'index.php?page=story-budget' ) ),
+ 'short_description' => sprintf( __( 'View the status of all your content at a glance.', 'edit-flow' ), esc_url( admin_url( 'index.php?page=story-budget' ) ) ),
'extended_description' => __( 'Use the story budget to see how content on your site is progressing. Filter by specific categories or date ranges to see details about each post in progress.', 'edit-flow' ),
'module_url' => $this->module_url,
'img_url' => $this->module_url . 'lib/story_budget_s128.png',
From e717259e1d268961955edbb1bd66b4241324f01e Mon Sep 17 00:00:00 2001
From: thisismyurl <122108986+thisismyurl@users.noreply.github.com>
Date: Tue, 22 Sep 2026 14:47:37 -0400
Subject: [PATCH 9/9] Address review feedback on #1016
Failing checks: WP latest and PHP latest
---
tests/Integration/UserGroupsAjaxTest.php | 3 +++
1 file changed, 3 insertions(+)
diff --git a/tests/Integration/UserGroupsAjaxTest.php b/tests/Integration/UserGroupsAjaxTest.php
index bc5c85e2..34e23aee 100644
--- a/tests/Integration/UserGroupsAjaxTest.php
+++ b/tests/Integration/UserGroupsAjaxTest.php
@@ -21,6 +21,9 @@ class UserGroupsAjaxTest extends AjaxTestCase {
protected function setUp(): void {
parent::setUp();
+ global $edit_flow;
+ $edit_flow->user_groups->install();
+
require_once ABSPATH . 'wp-admin/includes/ajax-actions.php';
}