Aleksandr Trofimov 1 месяц назад
Родитель
Сommit
4f8513af33

+ 53 - 0
tests/bootstrap.php

@@ -1,5 +1,8 @@
 <?php
 
+use PHPUnit\Framework\Attributes\After;
+use PHPUnit\Framework\Attributes\Before;
+
 define('BX_SKIP_INSTALL_CHECK', 1);
 
 $aPathInfo = pathinfo(__FILE__);
@@ -11,6 +14,56 @@ require_once($sHeaderPath);
 
 class BxDolTestCase extends \PHPUnit\Framework\TestCase
 {
+    /**
+     * @var list<callable>|null
+     */
+    private $_aExceptionHandlersBackup;
+
+    #[Before]
+    protected function bxSnapshotExceptionHandlers(): void
+    {
+        $this->_aExceptionHandlersBackup = $this->bxActiveExceptionHandlers();
+    }
+
+    #[After]
+    protected function bxRestoreExceptionHandlers(): void
+    {
+        if ($this->_aExceptionHandlersBackup === null)
+            return;
+
+        foreach ($this->bxActiveExceptionHandlers() as $mixedIgnored)
+            restore_exception_handler();
+
+        foreach ($this->_aExceptionHandlersBackup as $mixedHandler)
+            set_exception_handler($mixedHandler);
+
+        $this->_aExceptionHandlersBackup = null;
+    }
+
+    /**
+     * @return list<callable>
+     */
+    private function bxActiveExceptionHandlers(): array
+    {
+        $aHandlers = [];
+
+        while (true) {
+            $mixedHandler = set_exception_handler(static function (): void {});
+            restore_exception_handler();
+
+            if ($mixedHandler === null)
+                break;
+
+            $aHandlers[] = $mixedHandler;
+            restore_exception_handler();
+        }
+
+        $aHandlers = array_reverse($aHandlers);
+        foreach ($aHandlers as $mixedHandler)
+            set_exception_handler($mixedHandler);
+
+        return $aHandlers;
+    }
 
     protected function bxRequireAntispam()
     {

+ 106 - 0
tests/units/inc/UtilProcessTest.php

@@ -0,0 +1,106 @@
+<?php
+
+use PHPUnit\Framework\Attributes\DataProvider;
+
+/**
+ * Input typing, output escaping, and default password hashing.
+ */
+class UtilProcessTest extends \PHPUnit\Framework\TestCase
+{
+    #[DataProvider('providerForProcessInput')]
+    public function testProcessInput($mixedIn, $iType, $mixedOut)
+    {
+        $this->assertSame($mixedOut, bx_process_input($mixedIn, $iType));
+    }
+
+    static public function providerForProcessInput()
+    {
+        return [
+            ['42', BX_DATA_INT, 42],
+            [' 42 ', BX_DATA_INT, 42],
+            ['x', BX_DATA_INT, false],
+            ['', BX_DATA_INT, false],
+            ['3.14', BX_DATA_FLOAT, 3.14],
+            [' 2.5 ', BX_DATA_FLOAT, 2.5],
+            ['nope', BX_DATA_FLOAT, false],
+            ['on', BX_DATA_CHECKBOX, 'on'],
+            [' on ', BX_DATA_CHECKBOX, 'on'],
+            ['off', BX_DATA_CHECKBOX, ''],
+            ['1', BX_DATA_CHECKBOX, ''],
+            ['1985-10-28', BX_DATA_DATE, '1985-10-28'],
+            ['1985-1-2', BX_DATA_DATE, '1985-01-02'],
+            ['not-a-date', BX_DATA_DATE, false],
+            ['1985/10/28', BX_DATA_DATE, false],
+            ['1985-10-28T00:59:35', BX_DATA_DATETIME, '1985-10-28 00:59:35'],
+            ['1985-10-28 00:59:35', BX_DATA_DATETIME, '1985-10-28 00:59:35'],
+            ['1985-10-28 00:59', BX_DATA_DATETIME, '1985-10-28 00:59:00'],
+            ['1985-10-28', BX_DATA_DATETIME, '1985-10-28'],
+            [['1', '2'], BX_DATA_INT, [1, 2]],
+            ['plain', BX_DATA_TEXT, 'plain'],
+        ];
+    }
+
+    #[DataProvider('providerForJsString')]
+    public function testJsString($sIn, $iQuoteType, $sOut)
+    {
+        $this->assertSame($sOut, bx_js_string($sIn, $iQuoteType));
+    }
+
+    static public function providerForJsString()
+    {
+        return [
+            ['say "hi"', BX_ESCAPE_STR_AUTO, 'say &quot;hi&quot;'],
+            ["it's", BX_ESCAPE_STR_AUTO, 'it&apos;s'],
+            ['<script>x</script>', BX_ESCAPE_STR_AUTO, '&lt;script&gt;x&lt;/script&gt;'],
+            ["it's", BX_ESCAPE_STR_APOS, "it\\'s"],
+            ['<script>x</script>', BX_ESCAPE_STR_APOS, "<scr' + 'ipt>x</scr' + 'ipt>"],
+            ['say "hi"', BX_ESCAPE_STR_QUOTE, 'say \\"hi\\"'],
+            ['<script>x</script>', BX_ESCAPE_STR_QUOTE, '<scr" + "ipt>x</scr" + "ipt>'],
+            ["line\nbreak", BX_ESCAPE_STR_AUTO, 'line\\nbreak'],
+        ];
+    }
+
+    #[DataProvider('providerForHtmlAttribute')]
+    public function testHtmlAttribute($sIn, $iQuoteType, $sOut)
+    {
+        $this->assertSame($sOut, bx_html_attribute($sIn, $iQuoteType));
+    }
+
+    static public function providerForHtmlAttribute()
+    {
+        return [
+            ['say "hi"', BX_ESCAPE_STR_AUTO, 'say &quot;hi&quot;'],
+            ["it's", BX_ESCAPE_STR_AUTO, 'it&apos;s'],
+            ["it's", BX_ESCAPE_STR_APOS, "it\\'s"],
+            ['say "hi"', BX_ESCAPE_STR_QUOTE, 'say &quot;hi&quot;'],
+            ["it's", BX_ESCAPE_STR_QUOTE, "it's"],
+        ];
+    }
+
+    #[DataProvider('providerForHtmlspecialcharsAdv')]
+    public function testHtmlspecialcharsAdv($sIn, $sOut)
+    {
+        $this->assertSame($sOut, htmlspecialchars_adv($sIn));
+    }
+
+    static public function providerForHtmlspecialcharsAdv()
+    {
+        return [
+            ['a & b', 'a &amp; b'],
+            ['say "hi"', 'say &quot;hi&quot;'],
+            ["it's", "it's"],
+            ['&amp;', '&amp;'],
+        ];
+    }
+
+    public function testEncryptUserPwdDefaultAlgo()
+    {
+        $sAlgo = defined('BX_PWD_ALGO') ? BX_PWD_ALGO : '';
+        if ($sAlgo && $sAlgo !== 'sha1_md5_salt')
+            $this->markTestSkipped('BX_PWD_ALGO is not sha1_md5_salt');
+
+        $sPwd = 'secret';
+        $sSalt = 'pepper';
+        $this->assertSame(sha1(md5($sPwd) . $sSalt), encryptUserPwd($sPwd, $sSalt));
+    }
+}

+ 1 - 1
tests/units/inc/UtilTest.php

@@ -5,7 +5,7 @@ use PHPUnit\Framework\Attributes\DataProvider;
 /**
  * Test util functions
  */
-class UtilTest extends \PHPUnit\Framework\TestCase
+class UtilTest extends BxDolTestCase
 {
     /**
      * @see clear_xss

+ 158 - 0
tests/units/inc/UtilUrlTest.php

@@ -0,0 +1,158 @@
+<?php
+
+use PHPUnit\Framework\Attributes\DataProvider;
+
+/**
+ * URL helpers, link detection, markers, and method-name conversion.
+ */
+class UtilUrlTest extends \PHPUnit\Framework\TestCase
+{
+    #[DataProvider('providerForEncodeUrlParams')]
+    public function testEncodeUrlParams($a, $aExclude, $aOnly, $sOut)
+    {
+        $this->assertSame($sOut, bx_encode_url_params($a, $aExclude, $aOnly));
+    }
+
+    static public function providerForEncodeUrlParams()
+    {
+        return [
+            [['a' => '1', 'b' => '2'], [], false, 'a=1&b=2&'],
+            [['a' => '1', 'b' => '2'], ['b'], false, 'a=1&'],
+            [['a' => '1', 'b' => '2'], [], ['b'], 'b=2&'],
+            [['q' => 'a b'], [], false, 'q=a%20b&'],
+            [['ids' => ['1', '2']], [], false, 'ids[]=1&ids[]=2&'],
+        ];
+    }
+
+    #[DataProvider('providerForParseStr')]
+    public function testParseStr($sIn, $aOut)
+    {
+        $this->assertSame($aOut, bx_parse_str($sIn));
+    }
+
+    static public function providerForParseStr()
+    {
+        return [
+            ['a=1&b=2', ['a' => '1', 'b' => '2']],
+            ['q=%20', ['q' => '%20']],
+            ['ids[]=1&ids[]=2', ['ids' => ['1', '2']]],
+            ['a=1&a=2', ['a' => ['1', '2']]],
+        ];
+    }
+
+    #[DataProvider('providerForAppendUrlParams')]
+    public function testAppendUrlParams($sUrl, $mixedParams, $bEncode, $sOut)
+    {
+        $this->assertSame($sOut, bx_append_url_params($sUrl, $mixedParams, $bEncode));
+    }
+
+    static public function providerForAppendUrlParams()
+    {
+        return [
+            ['http://example.com', ['a' => '1'], true, 'http://example.com?a=1'],
+            ['http://example.com?x=1', ['a' => '1'], true, 'http://example.com?x=1&a=1'],
+            ['http://example.com', ['q' => 'a b'], true, 'http://example.com?q=a%20b'],
+            ['http://example.com', ['q' => 'a b'], false, 'http://example.com?q=a b'],
+            ['http://example.com', [], true, 'http://example.com'],
+            ['http://example.com', ['ids' => ['1', '2']], true, 'http://example.com?ids[]=1&ids[]=2'],
+        ];
+    }
+
+    public function testLinkifyExternalUrl()
+    {
+        $s = bx_linkify('see www.example.com now');
+        $this->assertStringContainsString('<a ', $s);
+        $this->assertStringContainsString('href="http://www.example.com"', $s);
+        $this->assertStringContainsString('target="_blank"', $s);
+        if (getParam('sys_add_nofollow') == 'on')
+            $this->assertStringContainsString('rel="nofollow"', $s);
+        else
+            $this->assertStringNotContainsString('rel="nofollow"', $s);
+    }
+
+    public function testLinkifyLocalUrlDoesNotAddTargetBlank()
+    {
+        if (!defined('BX_DOL_URL_ROOT') || !BX_DOL_URL_ROOT)
+            $this->markTestSkipped('BX_DOL_URL_ROOT is not defined');
+
+        $s = bx_linkify('go ' . BX_DOL_URL_ROOT . 'page');
+        $this->assertStringContainsString('href="' . BX_DOL_URL_ROOT . 'page"', $s);
+        $this->assertStringNotContainsString('target="_blank"', $s);
+    }
+
+    #[DataProvider('providerForIsUrlInContent')]
+    public function testIsUrlInContent($sContent, $bSkipLocal, $bOut)
+    {
+        $this->assertSame($bOut, bx_is_url_in_content($sContent, $bSkipLocal));
+    }
+
+    static public function providerForIsUrlInContent()
+    {
+        return [
+            ['hello http://example.com', false, true],
+            ['hello https://example.com', false, true],
+            ['visit www.example.com', false, true],
+            ['file.com archive', false, true],
+            ['no links here', false, false],
+        ];
+    }
+
+    public function testIsUrlInContentSkipsLocal()
+    {
+        if (!defined('BX_DOL_URL_ROOT') || !BX_DOL_URL_ROOT)
+            $this->markTestSkipped('BX_DOL_URL_ROOT is not defined');
+
+        $this->assertFalse(bx_is_url_in_content(BX_DOL_URL_ROOT . 'only', true));
+        $this->assertTrue(bx_is_url_in_content(BX_DOL_URL_ROOT . ' and http://example.com', true));
+    }
+
+    #[DataProvider('providerForGenMethodName')]
+    public function testGenMethodName($sIn, $sOut)
+    {
+        $this->assertSame($sOut, bx_gen_method_name($sIn));
+    }
+
+    static public function providerForGenMethodName()
+    {
+        return [
+            ['some_method', 'SomeMethod'],
+            ['is_spam', 'IsSpam'],
+            ['alreadyCamel', 'AlreadyCamel'],
+            ['one', 'One'],
+        ];
+    }
+
+    #[DataProvider('providerForLtrimStr')]
+    public function testLtrimStr($sString, $sPrefix, $sReplace, $sOut)
+    {
+        $this->assertSame($sOut, bx_ltrim_str($sString, $sPrefix, $sReplace));
+    }
+
+    static public function providerForLtrimStr()
+    {
+        return [
+            ['/var/www/file', '/var/www/', '', 'file'],
+            ['file', '/var/www/', '', 'file'],
+            ['/var/www/file', '/var/www/', 'ROOT/', 'ROOT/file'],
+            ['ROOT/file', '/var/www/', 'ROOT/', 'ROOT/file'],
+        ];
+    }
+
+    #[DataProvider('providerForReplaceMarkers')]
+    public function testReplaceMarkers($mixed, $aMarkers, $mixedOut)
+    {
+        $this->assertSame($mixedOut, bx_replace_markers($mixed, $aMarkers));
+    }
+
+    static public function providerForReplaceMarkers()
+    {
+        return [
+            ['Hello {name}', ['name' => 'Ada'], 'Hello Ada'],
+            ['{a} and {b}', ['a' => '1', 'b' => '2'], '1 and 2'],
+            ['unchanged', [], 'unchanged'],
+            ['{missing}', ['name' => 'Ada'], '{missing}'],
+            [['x' => 'hi {name}'], ['name' => 'Ada'], ['x' => 'hi Ada']],
+            ['null {x}', ['x' => null], 'null '],
+        ];
+    }
+}

+ 100 - 0
tests/units/inc/classes/BxDolAclTest.php

@@ -0,0 +1,100 @@
+<?php
+
+use PHPUnit\Framework\Attributes\DataProvider;
+
+class BxDolAclTestDouble extends BxDolAcl
+{
+    public $iLevelBit = 0;
+
+    public function __construct()
+    {
+    }
+
+    public function getMemberLevelBit($iProfileId = 0)
+    {
+        return $this->iLevelBit;
+    }
+}
+
+/**
+ * Membership bitsets and search-condition structure (no DB tracking).
+ */
+class BxDolAclTest extends \PHPUnit\Framework\TestCase
+{
+    #[DataProvider('providerForMemberLevelInSet')]
+    public function testIsMemberLevelInSet($iLevelBit, $mixedPermissions, $bOut)
+    {
+        $o = new BxDolAclTestDouble();
+        $o->iLevelBit = $iLevelBit;
+        $this->assertSame($bOut, (bool)$o->isMemberLevelInSet($mixedPermissions));
+    }
+
+    static public function providerForMemberLevelInSet()
+    {
+        $iStandardBit = (int)pow(2, MEMBERSHIP_ID_STANDARD - 1);
+        $iAdminBit = (int)pow(2, MEMBERSHIP_ID_ADMINISTRATOR - 1);
+
+        return [
+            [$iStandardBit, [MEMBERSHIP_ID_STANDARD], true],
+            [$iStandardBit, [MEMBERSHIP_ID_ADMINISTRATOR], false],
+            [$iStandardBit, $iStandardBit, true],
+            [$iStandardBit, $iAdminBit, false],
+            [$iStandardBit, [MEMBERSHIP_ID_STANDARD, MEMBERSHIP_ID_ADMINISTRATOR], true],
+            [$iStandardBit, [], false],
+            [$iStandardBit, 0, false],
+        ];
+    }
+
+    public function testContentByLevelAsConditionUnconfirmed()
+    {
+        $o = new BxDolAclTestDouble();
+        $a = $o->getContentByLevelAsCondition('author_id', MEMBERSHIP_ID_UNCONFIRMED);
+
+        $this->assertStringContainsString('email_confirmed` = 0', $a['restriction_sql']);
+        $this->assertSame([], $a['restriction']);
+        $this->assertSame([], $a['join']);
+    }
+
+    public function testContentByLevelAsConditionUnconfirmedFromSingleElementArray()
+    {
+        $o = new BxDolAclTestDouble();
+        $a = $o->getContentByLevelAsCondition('author_id', [MEMBERSHIP_ID_UNCONFIRMED]);
+
+        $this->assertStringContainsString('email_confirmed` = 0', $a['restriction_sql']);
+        $this->assertSame([], $a['join']);
+    }
+
+    public function testContentByLevelAsConditionStandard()
+    {
+        $o = new BxDolAclTestDouble();
+        $a = $o->getContentByLevelAsCondition('author_id', MEMBERSHIP_ID_STANDARD);
+
+        $this->assertStringContainsString('IDMember` IS NULL', $a['restriction_sql']);
+        $this->assertStringContainsString('email_confirmed` != 0', $a['restriction_sql']);
+        $this->assertSame('LEFT', $a['join']['acl_members']['type']);
+        $this->assertSame('author_id', $a['join']['acl_members']['mainField']);
+        $this->assertSame([], $a['restriction']);
+    }
+
+    public function testContentByLevelAsConditionCustom()
+    {
+        $o = new BxDolAclTestDouble();
+        $a = $o->getContentByLevelAsCondition('author_id', MEMBERSHIP_ID_MODERATOR);
+
+        $this->assertSame('INNER', $a['join']['acl_members']['type']);
+        $this->assertSame('=', $a['restriction']['acl_members']['operator']);
+        $this->assertSame(MEMBERSHIP_ID_MODERATOR, $a['restriction']['acl_members']['value']);
+        $this->assertStringContainsString('email_confirmed` != 0', $a['restriction_sql']);
+    }
+
+    public function testContentByLevelAsConditionCustomListUsesIn()
+    {
+        $o = new BxDolAclTestDouble();
+        $aLevels = [MEMBERSHIP_ID_MODERATOR, MEMBERSHIP_ID_ADMINISTRATOR];
+        $a = $o->getContentByLevelAsCondition('author_id', $aLevels);
+
+        $this->assertSame('in', $a['restriction']['acl_members']['operator']);
+        $this->assertSame($aLevels, $a['restriction']['acl_members']['value']);
+        $this->assertSame('INNER', $a['join']['acl_members']['type']);
+    }
+}

+ 106 - 0
tests/units/inc/classes/BxDolContentFilterTest.php

@@ -0,0 +1,106 @@
+<?php
+
+use PHPUnit\Framework\Attributes\DataProvider;
+
+class BxDolContentFilterTestDouble extends BxDolContentFilter
+{
+    public $aProhibited = [];
+    public $aUnauthenticated = [];
+    public $aViewerInfo = false;
+
+    public function __construct()
+    {
+        $this->_iDefaultValue = 1;
+        $this->_iViewerId = 0;
+    }
+
+    public function getProhibited()
+    {
+        return $this->aProhibited;
+    }
+
+    public function getUnauthenticated()
+    {
+        return $this->aUnauthenticated;
+    }
+
+    public function isAllowedByViewer($iValue, $iViewerId = 0)
+    {
+        $iCfDefault = $this->getDefaultValue();
+        if (!$iValue)
+            $iValue = $iCfDefault;
+
+        if (is_array($this->aViewerInfo) && isset($this->aViewerInfo['cfw_value']))
+            $iCfwValue = $this->aViewerInfo['cfw_value'];
+        else
+            $iCfwValue = $this->getDefaultValueUnauthenticated();
+
+        return (1 << ($iValue - 1)) & $iCfwValue;
+    }
+}
+
+/**
+ * Content-filter bitmasks and guest defaults (no SQL execution).
+ */
+class BxDolContentFilterTest extends \PHPUnit\Framework\TestCase
+{
+    #[DataProvider('providerForAllowedBySetting')]
+    public function testIsAllowedBySetting($aProhibited, $iValue, $bOut)
+    {
+        $o = new BxDolContentFilterTestDouble();
+        $o->aProhibited = $aProhibited;
+        $this->assertSame($bOut, $o->isAllowedBySetting($iValue));
+    }
+
+    static public function providerForAllowedBySetting()
+    {
+        return [
+            [[], 2, true],
+            [['3'], 1, true],
+            [['3'], 3, false],
+            [[2, 3], 2, false],
+        ];
+    }
+
+    public function testDefaultValueUnauthenticatedBitmask()
+    {
+        $o = new BxDolContentFilterTestDouble();
+        $o->aUnauthenticated = [1, 3];
+        $this->assertSame((1 << 0) | (1 << 2), $o->getDefaultValueUnauthenticated());
+    }
+
+    public function testDefaultValueUnauthenticatedEmpty()
+    {
+        $o = new BxDolContentFilterTestDouble();
+        $o->aUnauthenticated = [];
+        $this->assertSame(0, $o->getDefaultValueUnauthenticated());
+    }
+
+    public function testIsAllowedByViewerWithProfileBits()
+    {
+        $o = new BxDolContentFilterTestDouble();
+        $o->aViewerInfo = ['cfw_value' => (1 << 0) | (1 << 2)];
+
+        $this->assertNotFalse($o->isAllowedByViewer(1));
+        $this->assertFalse((bool)$o->isAllowedByViewer(2));
+        $this->assertNotFalse($o->isAllowedByViewer(3));
+    }
+
+    public function testIsAllowedByViewerGuestUsesUnauthenticatedDefault()
+    {
+        $o = new BxDolContentFilterTestDouble();
+        $o->aViewerInfo = false;
+        $o->aUnauthenticated = [1];
+
+        $this->assertNotFalse($o->isAllowedByViewer(1));
+        $this->assertFalse((bool)$o->isAllowedByViewer(2));
+    }
+
+    public function testIsAllowedByViewerZeroValueUsesDefaultFilter()
+    {
+        $o = new BxDolContentFilterTestDouble();
+        $o->aViewerInfo = ['cfw_value' => 1];
+
+        $this->assertNotFalse($o->isAllowedByViewer(0));
+    }
+}

+ 23 - 0
tests/units/inc/classes/BxDolDbTest.php

@@ -28,6 +28,29 @@ class BxDolDbTest extends BxDolTestCase
             array('1234a', true),
             array('12345', false),
             array('💡', false),
+            array('id;', true),
+            array('`id`', false),
         );
     }
+
+    #[DataProvider('providerForIsValidOperator')]
+    public function testIsValidOperator($s, $bRes)
+    {
+        $this->assertSame($bRes, BxDolDb::getInstance()->isValidOperator($s));
+    }
+
+    static public function providerForIsValidOperator()
+    {
+        return [
+            ['=', true],
+            ['like', true],
+            ['LIKE', true],
+            ['  in  ', true],
+            ['NOT IN', true],
+            ['OR 1=1', false],
+            [';DROP', false],
+            ['', false],
+            ['UNION', false],
+        ];
+    }
 }

+ 186 - 0
tests/units/inc/classes/BxDolFormCheckerHelperTest.php

@@ -0,0 +1,186 @@
+<?php
+
+use PHPUnit\Framework\Attributes\DataProvider;
+
+/**
+ * Form field validators (no captcha, location, or spam checks).
+ */
+class BxDolFormCheckerHelperTest extends \PHPUnit\Framework\TestCase
+{
+    public static function setUpBeforeClass(): void
+    {
+        if (!class_exists('BxDolFormCheckerHelper', false))
+            bx_import('BxDolForm');
+    }
+
+    #[DataProvider('providerForCheckEmail')]
+    public function testCheckEmail($s, $bOut)
+    {
+        $this->assertSame($bOut, (bool)BxDolFormCheckerHelper::checkEmail($s));
+    }
+
+    static public function providerForCheckEmail()
+    {
+        return [
+            ['user@example.com', true],
+            ['тест@example.com', true],
+            ['user.name@example.com', true],
+            ['user_name@example.com', true],
+            ['', false],
+            ['no-at', false],
+            ['a@b@c.com', false],
+            ['.user@example.com', false],
+            ['user@exam_ple.com', false],
+        ];
+    }
+
+    public function testCheckEmailPlusAddressing()
+    {
+        $sKey = 'sys_account_allow_plus_in_email';
+        $oProp = new ReflectionProperty(BxDolDb::class, '_aParams');
+        $aParams = $oProp->getValue();
+        $sOld = $aParams[$sKey] ?? '';
+
+        try {
+            $aParams[$sKey] = 'on';
+            $oProp->setValue(null, $aParams);
+            $this->assertTrue((bool)BxDolFormCheckerHelper::checkEmail('user+tag@example.com'));
+
+            $aParams[$sKey] = '';
+            $oProp->setValue(null, $aParams);
+            $this->assertFalse((bool)BxDolFormCheckerHelper::checkEmail('user+tag@example.com'));
+        } finally {
+            $aParams[$sKey] = $sOld;
+            $oProp->setValue(null, $aParams);
+        }
+    }
+
+    public function testCheckEmailOrEmpty()
+    {
+        $this->assertTrue((bool)BxDolFormCheckerHelper::checkEmailOrEmpty(''));
+        $this->assertTrue((bool)BxDolFormCheckerHelper::checkEmailOrEmpty('user@example.com'));
+        $this->assertFalse((bool)BxDolFormCheckerHelper::checkEmailOrEmpty('no-at'));
+    }
+
+    #[DataProvider('providerForCheckLength')]
+    public function testCheckLength($mixed, $iMin, $iMax, $bOut)
+    {
+        $this->assertSame($bOut, (bool)BxDolFormCheckerHelper::checkLength($mixed, $iMin, $iMax));
+    }
+
+    static public function providerForCheckLength()
+    {
+        return [
+            ['ab', 1, 3, true],
+            ['abcd', 1, 3, false],
+            ['', 1, 3, false],
+            ['a', 1, 1, true],
+            [['ab', 'cd'], 1, 3, true],
+            [['abcd'], 1, 3, false],
+            ['тест', 1, 4, true],
+            ['тест', 1, 3, false],
+        ];
+    }
+
+    #[DataProvider('providerForCheckPreg')]
+    public function testCheckPreg($mixed, $sRegex, $bOut)
+    {
+        $this->assertSame($bOut, (bool)BxDolFormCheckerHelper::checkPreg($mixed, $sRegex));
+    }
+
+    static public function providerForCheckPreg()
+    {
+        return [
+            ['abc', '#^ab#', true],
+            ['x', '#^ab#', false],
+            [['abc', 'abd'], '#^ab#', true],
+            [['abc', 'x'], '#^ab#', false],
+        ];
+    }
+
+    #[DataProvider('providerForCheckAvail')]
+    public function testCheckAvail($mixed, $bOut)
+    {
+        $this->assertSame($bOut, (bool)BxDolFormCheckerHelper::checkAvail($mixed));
+    }
+
+    static public function providerForCheckAvail()
+    {
+        return [
+            ['x', true],
+            ['', false],
+            ['0', false],
+            [[], false],
+            [['x'], true],
+            [[''], false],
+        ];
+    }
+
+    #[DataProvider('providerForCheckJson')]
+    public function testCheckJson($mixed, $bAllowEmpty, $bOut)
+    {
+        $this->assertSame($bOut, (bool)BxDolFormCheckerHelper::checkJson($mixed, $bAllowEmpty));
+    }
+
+    static public function providerForCheckJson()
+    {
+        return [
+            ['{"a":1}', false, true],
+            ['[]', false, true],
+            ['null', false, true],
+            ['not json', false, false],
+            ['', false, false],
+            ['', true, true],
+        ];
+    }
+
+    #[DataProvider('providerForCheckDate')]
+    public function testCheckDate($s, $bOut)
+    {
+        $this->assertSame($bOut, (bool)BxDolFormCheckerHelper::checkDate($s));
+    }
+
+    static public function providerForCheckDate()
+    {
+        return [
+            ['2024-01-15', true],
+            ['2024/01/15', false],
+            ['15-01-2024', true],
+            ['not-a-date', false],
+            ['', false],
+        ];
+    }
+
+    #[DataProvider('providerForCheckDateTime')]
+    public function testCheckDateTime($s, $bOut)
+    {
+        $this->assertSame($bOut, (bool)BxDolFormCheckerHelper::checkDateTime($s));
+    }
+
+    static public function providerForCheckDateTime()
+    {
+        return [
+            ['2024-01-15 10:30', true],
+            ['2024-01-15 10:30:00', true],
+            ['2024-01-15T10:30:00', true],
+            ['2024-01-15', false],
+            ['not-a-date', false],
+        ];
+    }
+
+    #[DataProvider('providerForCheckProfileName')]
+    public function testCheckProfileName($s, $bOut)
+    {
+        $this->assertSame($bOut, (bool)BxDolFormCheckerHelper::checkProfileName($s));
+    }
+
+    static public function providerForCheckProfileName()
+    {
+        return [
+            ['Ada', true],
+            ['Ada Lovelace', true],
+            ['', false],
+            ['@handle', false],
+        ];
+    }
+}

+ 60 - 0
tests/units/inc/classes/BxDolPrivacyTest.php

@@ -0,0 +1,60 @@
+<?php
+
+use PHPUnit\Framework\Attributes\DataProvider;
+
+class BxDolPrivacyTestDouble extends BxDolPrivacy
+{
+    public function __construct()
+    {
+    }
+
+    public function convertActionToFieldPublic($sAction)
+    {
+        return $this->convertActionToField($sAction);
+    }
+
+    public function getCheckMethodPublic($s)
+    {
+        return $this->getCheckMethod($s);
+    }
+}
+
+/**
+ * Privacy field and check-method naming.
+ */
+class BxDolPrivacyTest extends \PHPUnit\Framework\TestCase
+{
+    #[DataProvider('providerForConvertActionToField')]
+    public function testConvertActionToField($sAction, $sField)
+    {
+        $o = new BxDolPrivacyTestDouble();
+        $this->assertSame($sField, $o->convertActionToFieldPublic($sAction));
+    }
+
+    static public function providerForConvertActionToField()
+    {
+        return [
+            ['view', 'allow_view_to'],
+            ['comment', 'allow_comment_to'],
+            ['view comments', 'allow_view-comments_to'],
+            ['View', 'allow_view_to'],
+        ];
+    }
+
+    #[DataProvider('providerForGetCheckMethod')]
+    public function testGetCheckMethod($s, $mixedOut)
+    {
+        $o = new BxDolPrivacyTestDouble();
+        $this->assertSame($mixedOut, $o->getCheckMethodPublic($s));
+    }
+
+    static public function providerForGetCheckMethod()
+    {
+        return [
+            ['@foo', 'CheckFoo'],
+            ['@foo_bar', 'CheckFooBar'],
+            ['foo', false],
+            ['check_foo', false],
+        ];
+    }
+}

+ 64 - 0
tests/units/inc/classes/BxDolServiceMacroTest.php

@@ -0,0 +1,64 @@
+<?php
+
+use PHPUnit\Framework\Attributes\DataProvider;
+
+/**
+ * Macro parse errors and service-request path/class guards (no real module dispatch).
+ */
+class BxDolServiceMacroTest extends \PHPUnit\Framework\TestCase
+{
+    #[DataProvider('providerForMalformedMacro')]
+    public function testCallMacroMalformed($s)
+    {
+        $this->assertSame(_t('_sys_macros_malformed'), BxDolService::callMacro($s));
+    }
+
+    static public function providerForMalformedMacro()
+    {
+        return [
+            [''],
+            ['not-a-macro'],
+            ['system'],
+            ['system:test{'],
+            ['system:test{not json}'],
+        ];
+    }
+
+    #[DataProvider('providerForMacrosInContent')]
+    public function testIsMacrosInContent($s, $bOut)
+    {
+        $this->assertSame($bOut, bx_is_macros_in_content($s));
+    }
+
+    static public function providerForMacrosInContent()
+    {
+        return [
+            ['plain text', false],
+            ['before {{~system:method}} after', true],
+            ['{{~', true],
+            ['{~not a macro', false],
+        ];
+    }
+
+    public function testCheckCallRejectsTraversalPath()
+    {
+        $aModule = [
+            'name' => 'evil',
+            'path' => '../evil/',
+            'class_prefix' => 'Bx',
+            'uri' => 'evil',
+        ];
+        $this->assertSame(1, BxDolRequest::checkCall($aModule, 'foo', [], 'Module'));
+    }
+
+    public function testCheckCallRejectsInvalidClassName()
+    {
+        $aModule = [
+            'name' => 'test',
+            'path' => 'boonex/test/',
+            'class_prefix' => '',
+            'uri' => 'test',
+        ];
+        $this->assertSame(1, BxDolRequest::checkCall($aModule, 'foo', [], 'Foo-Bar'));
+    }
+}

+ 64 - 0
tests/units/inc/classes/BxDolStorageExtTest.php

@@ -0,0 +1,64 @@
+<?php
+
+use PHPUnit\Framework\Attributes\DataProvider;
+
+class BxDolStorageExtTestDouble extends BxDolStorage
+{
+    public function __construct($aObject)
+    {
+        $this->_aObject = $aObject;
+    }
+
+    public function isValidExtPublic($sExt)
+    {
+        return $this->isValidExt($sExt);
+    }
+
+    public function genPathPublic($s, $iLevels)
+    {
+        return $this->genPath($s, $iLevels);
+    }
+}
+
+/**
+ * Upload extension policy and generated storage paths (no disk I/O).
+ */
+class BxDolStorageExtTest extends \PHPUnit\Framework\TestCase
+{
+    #[DataProvider('providerForValidExt')]
+    public function testIsValidExt($aObject, $sExt, $bOut)
+    {
+        $o = new BxDolStorageExtTestDouble($aObject);
+        $this->assertSame($bOut, $o->isValidExtPublic($sExt));
+    }
+
+    static public function providerForValidExt()
+    {
+        return [
+            [['ext_mode' => 'allow-deny', 'ext_allow' => 'jpg,png', 'ext_deny' => ''], 'jpg', true],
+            [['ext_mode' => 'allow-deny', 'ext_allow' => 'jpg,png', 'ext_deny' => ''], 'exe', false],
+            [['ext_mode' => 'allow-deny', 'ext_allow' => '', 'ext_deny' => ''], 'jpg', false],
+            [['ext_mode' => 'deny-allow', 'ext_allow' => '', 'ext_deny' => 'exe,php'], 'jpg', true],
+            [['ext_mode' => 'deny-allow', 'ext_allow' => '', 'ext_deny' => 'exe,php'], 'php', false],
+            [['ext_mode' => 'deny-allow', 'ext_allow' => '', 'ext_deny' => ''], 'php', true],
+            [['ext_mode' => 'unknown', 'ext_allow' => 'jpg', 'ext_deny' => ''], 'jpg', false],
+        ];
+    }
+
+    #[DataProvider('providerForGenPath')]
+    public function testGenPath($s, $iLevels, $sOut)
+    {
+        $o = new BxDolStorageExtTestDouble(['ext_mode' => 'allow-deny', 'ext_allow' => '', 'ext_deny' => '']);
+        $this->assertSame($sOut, $o->genPathPublic($s, $iLevels));
+    }
+
+    static public function providerForGenPath()
+    {
+        return [
+            ['abc', 2, 'a/ab/'],
+            ['abc', 1, 'a/'],
+            ['abc', 0, ''],
+            ['abcdef', 3, 'a/ab/abc/'],
+        ];
+    }
+}