diff --git a/tests/Unit/Libraries/Cms/Form/Filter/IntarrayFilterTest.php b/tests/Unit/Libraries/Cms/Form/Filter/IntarrayFilterTest.php
new file mode 100644
index 0000000000000..41f80f4493d44
--- /dev/null
+++ b/tests/Unit/Libraries/Cms/Form/Filter/IntarrayFilterTest.php
@@ -0,0 +1,111 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Tests\Unit\Libraries\Cms\Form\Filter;
+
+use Joomla\CMS\Form\Filter\IntarrayFilter;
+use Joomla\Tests\Unit\UnitTestCase;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\TestDox;
+
+/**
+ * Test class for IntarrayFilter.
+ *
+ * The filter casts the value to an array of integers. Only the element is read, and only to raise
+ * the deprecation for the legacy `INT_ARRAY` filter name, so the group, input registry and form are
+ * passed as null.
+ *
+ * @package Joomla.UnitTest
+ * @subpackage Form
+ * @since __DEPLOY_VERSION__
+ */
+#[TestDox('The intarray form filter')]
+class IntarrayFilterTest extends UnitTestCase
+{
+ /**
+ * Test data for the testFilter method.
+ *
+ * @return array
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public static function dataFilter(): array
+ {
+ return [
+ 'numeric string is wrapped' => [[5], '5'],
+ 'array of numeric strings' => [[1, 2], ['1', '2']],
+ 'non numeric member becomes 0' => [[1, 2, 0], ['1', '2', 'x']],
+ 'empty array stays empty' => [[], []],
+ 'string keys are preserved' => [['x' => 7], ['x' => '7']],
+ 'non numeric string becomes 0' => [[0], 'abc'],
+ 'null becomes zero' => [[0], null],
+ 'float is truncated' => [[3], 3.9],
+ 'boolean true' => [[1], true],
+ ];
+ }
+
+ /**
+ * Tests that the value is cast to an array of integers.
+ *
+ * @param array $expected The expected integer array
+ * @param mixed $value The raw form field value
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[DataProvider('dataFilter')]
+ #[TestDox('casts the value to an array of integers')]
+ public function testFilter(array $expected, $value): void
+ {
+ $filter = new IntarrayFilter();
+ $element = new \SimpleXMLElement('');
+
+ $this->assertSame($expected, $filter->filter($element, $value, null, null, null));
+ }
+
+ /**
+ * Tests that an object is converted through its public properties, keeping their names as keys.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('converts an object through its public properties, keeping the property names as keys')]
+ public function testObjectKeepsItsPropertyNamesAsKeys(): void
+ {
+ $filter = new IntarrayFilter();
+ $element = new \SimpleXMLElement('');
+
+ $this->assertSame(
+ ['a' => 3, 'b' => 4],
+ $filter->filter($element, (object) ['a' => '3', 'b' => '4'], null, null, null)
+ );
+ }
+
+ /**
+ * Tests that the deprecated INT_ARRAY filter name still filters the value.
+ *
+ * The deprecation itself is raised with a suppressed trigger_error() and is therefore not
+ * observable from the test, so only the filtering is asserted here.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('still filters when the deprecated INT_ARRAY filter name is used')]
+ public function testDeprecatedFilterNameStillFilters(): void
+ {
+ $filter = new IntarrayFilter();
+ $element = new \SimpleXMLElement('');
+
+ $this->assertSame([1, 2], $filter->filter($element, ['1', '2'], null, null, null));
+ }
+}
diff --git a/tests/Unit/Libraries/Cms/Form/Filter/RawFilterTest.php b/tests/Unit/Libraries/Cms/Form/Filter/RawFilterTest.php
new file mode 100644
index 0000000000000..ce1e8bdd1a849
--- /dev/null
+++ b/tests/Unit/Libraries/Cms/Form/Filter/RawFilterTest.php
@@ -0,0 +1,86 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Tests\Unit\Libraries\Cms\Form\Filter;
+
+use Joomla\CMS\Form\Filter\RawFilter;
+use Joomla\Tests\Unit\UnitTestCase;
+use PHPUnit\Framework\Attributes\TestDox;
+
+/**
+ * Test class for RawFilter.
+ *
+ * The filter hands the value straight back. The element, group, input registry and form are not
+ * read, so they are all passed as null.
+ *
+ * @package Joomla.UnitTest
+ * @subpackage Form
+ * @since __DEPLOY_VERSION__
+ */
+#[TestDox('The raw form filter')]
+class RawFilterTest extends UnitTestCase
+{
+ /**
+ * Tests that scalar values are handed back untouched.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('returns scalar values untouched')]
+ public function testScalarValuesArePassedThrough(): void
+ {
+ $filter = new RawFilter();
+ $element = new \SimpleXMLElement('');
+
+ $this->assertSame('markup', $filter->filter($element, 'markup', null, null, null));
+ $this->assertSame('', $filter->filter($element, '', null, null, null));
+ $this->assertSame(0, $filter->filter($element, 0, null, null, null));
+ $this->assertNull($filter->filter($element, null, null, null, null));
+ $this->assertFalse($filter->filter($element, false, null, null, null));
+ }
+
+ /**
+ * Tests that arrays are handed back untouched, including nested ones.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('returns arrays untouched, including nested ones')]
+ public function testArraysArePassedThrough(): void
+ {
+ $filter = new RawFilter();
+ $element = new \SimpleXMLElement('');
+
+ $this->assertSame([], $filter->filter($element, [], null, null, null));
+
+ $nested = ['a' => ['b' => '']];
+
+ $this->assertSame($nested, $filter->filter($element, $nested, null, null, null));
+ }
+
+ /**
+ * Tests that an object is handed back as the very same instance rather than a copy.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('returns the very same object instance, not a copy')]
+ public function testObjectIsNotCopied(): void
+ {
+ $filter = new RawFilter();
+ $element = new \SimpleXMLElement('');
+ $object = (object) ['title' => 'Test'];
+
+ $this->assertSame($object, $filter->filter($element, $object, null, null, null));
+ }
+}
diff --git a/tests/Unit/Libraries/Cms/Form/Filter/RulesFilterTest.php b/tests/Unit/Libraries/Cms/Form/Filter/RulesFilterTest.php
new file mode 100644
index 0000000000000..3ed30ba1f6ef1
--- /dev/null
+++ b/tests/Unit/Libraries/Cms/Form/Filter/RulesFilterTest.php
@@ -0,0 +1,196 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Tests\Unit\Libraries\Cms\Form\Filter;
+
+use Joomla\CMS\Form\Filter\RulesFilter;
+use Joomla\Tests\Unit\UnitTestCase;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\TestDox;
+
+/**
+ * Test class for RulesFilter.
+ *
+ * The filter turns the permissions grid posted by the rules form field into the nested array of
+ * booleans a Rules object is built from. The element, group, input registry and form are not read,
+ * so they are all passed as null.
+ *
+ * @package Joomla.UnitTest
+ * @subpackage Form
+ * @since __DEPLOY_VERSION__
+ */
+#[TestDox('The rules form filter')]
+class RulesFilterTest extends UnitTestCase
+{
+ /**
+ * Test data for the testPermissionIsCastToBoolean method.
+ *
+ * Note that the check is a loose `$p == '1' || $p === 'true'`, so anything which loosely equals
+ * 1 is an allow and everything else which is not an empty string is a deny.
+ *
+ * @return array
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public static function dataPermissions(): array
+ {
+ return [
+ 'string one is allow' => [true, '1'],
+ 'string true is allow' => [true, 'true'],
+ 'integer one is allow' => [true, 1],
+ 'boolean true is allow' => [true, true],
+ 'leading zero is allow' => [true, '01'],
+ 'string zero is deny' => [false, '0'],
+ 'string false is deny' => [false, 'false'],
+ 'arbitrary string is deny' => [false, 'nonsense'],
+ 'minus one is deny' => [false, '-1'],
+ ];
+ }
+
+ /**
+ * Tests how a single posted permission value is cast.
+ *
+ * @param boolean $expected The boolean the value must be cast to
+ * @param mixed $value The posted permission value
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[DataProvider('dataPermissions')]
+ #[TestDox('casts a posted permission to a boolean')]
+ public function testPermissionIsCastToBoolean(bool $expected, $value): void
+ {
+ $filter = new RulesFilter();
+ $element = new \SimpleXMLElement('');
+
+ $result = $filter->filter($element, ['core.edit' => ['6' => $value]], null, null, null);
+
+ $this->assertSame(['core.edit' => [6 => $expected]], $result);
+ }
+
+ /**
+ * Tests that an inherited permission, posted as an empty string, is dropped.
+ *
+ * The identity is left out of the action entirely rather than being stored as false, which is
+ * what makes the difference between "inherit" and "deny" in the saved rules.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('drops an identity whose permission was left as inherited')]
+ public function testInheritedPermissionIsDropped(): void
+ {
+ $filter = new RulesFilter();
+ $element = new \SimpleXMLElement('');
+
+ $result = $filter->filter(
+ $element,
+ ['core.edit' => ['6' => '1', '7' => '', '8' => '0']],
+ null,
+ null,
+ null
+ );
+
+ $this->assertSame(['core.edit' => [6 => true, 8 => false]], $result);
+ }
+
+ /**
+ * Tests that an action all of whose identities are inherited survives as an empty array.
+ *
+ * The action key itself is created before the identities are walked, so it is kept even when
+ * nothing is stored under it.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('keeps an action whose identities were all inherited, as an empty array')]
+ public function testActionWithOnlyInheritedPermissionsSurvivesEmpty(): void
+ {
+ $filter = new RulesFilter();
+ $element = new \SimpleXMLElement('');
+
+ $result = $filter->filter($element, ['core.edit' => ['6' => '', '7' => '']], null, null, null);
+
+ $this->assertSame(['core.edit' => []], $result);
+ }
+
+ /**
+ * Tests that several actions are all filtered.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('filters every action in the grid')]
+ public function testEveryActionIsFiltered(): void
+ {
+ $filter = new RulesFilter();
+ $element = new \SimpleXMLElement('');
+
+ $result = $filter->filter(
+ $element,
+ [
+ 'core.edit' => ['6' => '1'],
+ 'core.delete' => ['6' => '0'],
+ 'core.edit.state' => ['6' => 'true', '7' => 'false'],
+ ],
+ null,
+ null,
+ null
+ );
+
+ $this->assertSame(
+ [
+ 'core.edit' => [6 => true],
+ 'core.delete' => [6 => false],
+ 'core.edit.state' => [6 => true, 7 => false],
+ ],
+ $result
+ );
+ }
+
+ /**
+ * Tests that an empty value produces an empty rules array.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('returns an empty array when nothing was posted')]
+ public function testEmptyValueProducesAnEmptyArray(): void
+ {
+ $filter = new RulesFilter();
+ $element = new \SimpleXMLElement('');
+
+ $this->assertSame([], $filter->filter($element, null, null, null, null));
+ $this->assertSame([], $filter->filter($element, [], null, null, null));
+ }
+
+ /**
+ * Tests that an object is accepted, since the value is cast to an array first.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('accepts the grid as an object')]
+ public function testObjectIsAccepted(): void
+ {
+ $filter = new RulesFilter();
+ $element = new \SimpleXMLElement('');
+
+ $result = $filter->filter($element, (object) ['core.edit' => ['6' => '1']], null, null, null);
+
+ $this->assertSame(['core.edit' => [6 => true]], $result);
+ }
+}
diff --git a/tests/Unit/Libraries/Cms/Form/Filter/SafehtmlFilterTest.php b/tests/Unit/Libraries/Cms/Form/Filter/SafehtmlFilterTest.php
new file mode 100644
index 0000000000000..fe5635c63e100
--- /dev/null
+++ b/tests/Unit/Libraries/Cms/Form/Filter/SafehtmlFilterTest.php
@@ -0,0 +1,124 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Tests\Unit\Libraries\Cms\Form\Filter;
+
+use Joomla\CMS\Filter\InputFilter;
+use Joomla\CMS\Form\Filter\SafehtmlFilter;
+use Joomla\Tests\Unit\UnitTestCase;
+use PHPUnit\Framework\Attributes\TestDox;
+
+/**
+ * Test class for SafehtmlFilter.
+ *
+ * The filter delegates to InputFilter in its block-defined-tags mode. Only the delegation and the
+ * handful of behaviours the form layer depends on are asserted here, the exhaustive cleaning matrix
+ * belongs to the tests of the filter package itself. The element, group, input registry and form
+ * are not read, so they are all passed as null.
+ *
+ * @package Joomla.UnitTest
+ * @subpackage Form
+ * @since __DEPLOY_VERSION__
+ */
+#[TestDox('The safehtml form filter')]
+class SafehtmlFilterTest extends UnitTestCase
+{
+ /**
+ * Resets the InputFilter instance cache, which is keyed by the constructor arguments and is
+ * otherwise shared with every other test in the run.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $this->setStaticProperty(InputFilter::class, 'instances', []);
+ }
+
+ /**
+ * Tests that a script element is removed.
+ *
+ * Note the element is removed but its text content is kept, which is what InputFilter does in
+ * this mode.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('removes a script element')]
+ public function testScriptElementIsRemoved(): void
+ {
+ $filter = new SafehtmlFilter();
+ $element = new \SimpleXMLElement('');
+
+ $result = $filter->filter($element, '
ok
', null, null, null);
+
+ $this->assertStringNotContainsString('