Просмотр исходного кода

Merge branch 'master' of https://github.com/unacms/una

AntonLV 1 месяц назад
Родитель
Сommit
e3bc775d0e

+ 1 - 0
.gitignore

@@ -33,6 +33,7 @@ build.properties
 composer.phar
 tests/vendor
 tests/composer.phar
+tests/.env
 tests/.phpunit.cache
 tests/.phpunit.result.cache
 samples/img/*

+ 22 - 0
build.xml

@@ -155,6 +155,26 @@
         </exec>
     </target>
 
+    <!-- ============================================  -->
+    <!-- Target: test-units                            -->
+    <!-- ============================================  -->
+    <target name="test-units">
+        <echo msg="Testing units..." />
+        <exec executable="${php}" dir="${project.basedir}" checkreturn="true">
+            <arg line="plugins/bin/phpunit -c tests/phpunit.xml --testsuite Units" />
+        </exec>
+    </target>
+
+    <!-- ============================================  -->
+    <!-- Target: test-integration                      -->
+    <!-- ============================================  -->
+    <target name="test-integration">
+        <echo msg="Testing integration..." />
+        <exec executable="${php}" dir="${project.basedir}" checkreturn="true">
+            <arg line="plugins/bin/phpunit -c tests/phpunit.xml --testsuite Integration --log-junit logs/junit-integration.xml" />
+        </exec>
+    </target>
+
     <!-- ============================================  -->
     <!-- Target: package                               -->
     <!-- ============================================  -->
@@ -421,6 +441,8 @@
                 <exclude name="phpcdp.xml" />
                 <exclude name="phpcpd.xml" />
                 <exclude name="junit.xml" />
+                <exclude name="junit-units.xml" />
+                <exclude name="junit-integration.xml" />
             </fileset>
         </delete>
         <delete includeemptydirs="true">

+ 5 - 2
composer.json

@@ -113,7 +113,8 @@
     },
     "require-dev": {
         "phpunit/phpunit": "12.*",
-        "phpmd/phpmd": "@stable"
+        "phpmd/phpmd": "@stable",
+        "vlucas/phpdotenv": "^5.6"
     },
     "scripts": {
         "post-update-cmd": [
@@ -126,6 +127,8 @@
             "test -f ./plugins/ffmpeg/ffmpeg/ffmpeg-linux-x64 && mv ./plugins/ffmpeg/ffmpeg/ffmpeg-linux-x64 ./plugins/ffmpeg/ffmpeg.exe && rm -rf ./plugins/ffmpeg/ffmpeg || true",
             "grep -Fq \"this->headers['Content-MD5']\" plugins/tpyo/amazon-s3-php-class/S3.php || patch -p1 --batch < patches/plugins_tpyo_amazon-s3-php-class.txt"
         ],
-        "test": "phpunit -c tests/phpunit.xml"
+        "test": "phpunit -c tests/phpunit.xml",
+        "test-units": "phpunit -c tests/phpunit.xml --testsuite Units --log-junit logs/junit-units.xml",
+        "test-integration": "phpunit -c tests/phpunit.xml --testsuite Integration --log-junit logs/junit-integration.xml"
     }
 }

+ 223 - 2
composer.lock

@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "5c0c64d5169a971ca1c5b588ae6a2a31",
+    "content-hash": "b5b527f6989da4ce4e3c899689393a51",
     "packages": [
         {
             "name": "achingbrain/php5-akismet",
@@ -4650,6 +4650,68 @@
             ],
             "time": "2024-05-06T16:37:16+00:00"
         },
+        {
+            "name": "graham-campbell/result-type",
+            "version": "v1.2.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/GrahamCampbell/Result-Type.git",
+                "reference": "adccca3324eece92ca35463648c12b9e6293c05b"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/adccca3324eece92ca35463648c12b9e6293c05b",
+                "reference": "adccca3324eece92ca35463648c12b9e6293c05b",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.2.5 || ^8.0",
+                "phpoption/phpoption": "^1.10"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^8.5.52 || ^9.6.34 || ^10.5.63 || ^11.5.55 || ^12.5.14"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "GrahamCampbell\\ResultType\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Graham Campbell",
+                    "email": "hello@gjcampbell.co.uk",
+                    "homepage": "https://github.com/GrahamCampbell"
+                }
+            ],
+            "description": "An Implementation Of The Result Type",
+            "keywords": [
+                "Graham Campbell",
+                "GrahamCampbell",
+                "Result Type",
+                "Result-Type",
+                "result"
+            ],
+            "support": {
+                "issues": "https://github.com/GrahamCampbell/Result-Type/issues",
+                "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.2.0"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/GrahamCampbell",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/graham-campbell/result-type",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-08-24T09:06:52+00:00"
+        },
         {
             "name": "myclabs/deep-copy",
             "version": "1.14.0",
@@ -5031,6 +5093,81 @@
             ],
             "time": "2023-12-11T08:22:20+00:00"
         },
+        {
+            "name": "phpoption/phpoption",
+            "version": "1.10.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/schmittjoh/php-option.git",
+                "reference": "67b192b6a42ec03944b972d6e633ddec78ad2c6d"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/67b192b6a42ec03944b972d6e633ddec78ad2c6d",
+                "reference": "67b192b6a42ec03944b972d6e633ddec78ad2c6d",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.2.5 || ^8.0"
+            },
+            "require-dev": {
+                "bamarni/composer-bin-plugin": "^1.8.2",
+                "phpunit/phpunit": "^8.5.54 || ^9.6.36 || ^10.5.64 || ^11.5.56 || ^12.5.33"
+            },
+            "type": "library",
+            "extra": {
+                "bamarni-bin": {
+                    "bin-links": true,
+                    "forward-command": false
+                },
+                "branch-alias": {
+                    "dev-master": "1.9-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "PhpOption\\": "src/PhpOption/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "Apache-2.0"
+            ],
+            "authors": [
+                {
+                    "name": "Johannes M. Schmitt",
+                    "email": "schmittjoh@gmail.com",
+                    "homepage": "https://github.com/schmittjoh"
+                },
+                {
+                    "name": "Graham Campbell",
+                    "email": "hello@gjcampbell.co.uk",
+                    "homepage": "https://github.com/GrahamCampbell"
+                }
+            ],
+            "description": "Option Type for PHP",
+            "keywords": [
+                "language",
+                "option",
+                "php",
+                "type"
+            ],
+            "support": {
+                "issues": "https://github.com/schmittjoh/php-option/issues",
+                "source": "https://github.com/schmittjoh/php-option/tree/1.10.0"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/GrahamCampbell",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-08-24T00:54:40+00:00"
+        },
         {
             "name": "phpunit/php-code-coverage",
             "version": "12.5.7",
@@ -6872,6 +7009,90 @@
                 }
             ],
             "time": "2025-12-08T11:19:18+00:00"
+        },
+        {
+            "name": "vlucas/phpdotenv",
+            "version": "v5.7.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/vlucas/phpdotenv.git",
+                "reference": "301c07936b16d88628b126b01d082ba153cf4c40"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/301c07936b16d88628b126b01d082ba153cf4c40",
+                "reference": "301c07936b16d88628b126b01d082ba153cf4c40",
+                "shasum": ""
+            },
+            "require": {
+                "ext-pcre": "*",
+                "graham-campbell/result-type": "^1.2",
+                "php": "^7.2.5 || ^8.0",
+                "phpoption/phpoption": "^1.10",
+                "symfony/polyfill-ctype": "^1.26",
+                "symfony/polyfill-mbstring": "^1.26",
+                "symfony/polyfill-php80": "^1.26"
+            },
+            "require-dev": {
+                "bamarni/composer-bin-plugin": "^1.8.2",
+                "ext-filter": "*",
+                "phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2"
+            },
+            "suggest": {
+                "ext-filter": "Required to use the boolean validator."
+            },
+            "type": "library",
+            "extra": {
+                "bamarni-bin": {
+                    "bin-links": true,
+                    "forward-command": false
+                },
+                "branch-alias": {
+                    "dev-master": "5.6-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Dotenv\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Graham Campbell",
+                    "email": "hello@gjcampbell.co.uk",
+                    "homepage": "https://github.com/GrahamCampbell"
+                },
+                {
+                    "name": "Vance Lucas",
+                    "email": "vance@vancelucas.com",
+                    "homepage": "https://github.com/vlucas"
+                }
+            ],
+            "description": "Loads environment variables from `.env` to `$_ENV` and `$_SERVER` automagically, and optionally to `getenv()`.",
+            "keywords": [
+                "dotenv",
+                "env",
+                "environment"
+            ],
+            "support": {
+                "issues": "https://github.com/vlucas/phpdotenv/issues",
+                "source": "https://github.com/vlucas/phpdotenv/tree/v5.7.0"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/GrahamCampbell",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/vlucas/phpdotenv",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-08-24T18:07:49+00:00"
         }
     ],
     "aliases": [],
@@ -6890,5 +7111,5 @@
         "php": ">=8.1"
     },
     "platform-dev": {},
-    "plugin-api-version": "2.6.0"
+    "plugin-api-version": "2.9.0"
 }

+ 1 - 1
package.json

@@ -167,7 +167,7 @@
     "@types/react": "^19.2.18",
     "@types/react-dom": "^19.2.7",
     "bower": "^1.8.14",
-    "lucide": "^1.42.0",
+    "lucide": "^1.45.0",
     "lucide-static": "^1.45.0",
     "npm-run-all": "^4.1.5",
     "partial-json": "^0.1.7",

+ 7 - 0
tests/.env.example

@@ -0,0 +1,7 @@
+# Copy to tests/.env to override. tests/.env is gitignored.
+# Admin matches install/cmd.php defaults (php cmd.php).
+# Regular user is not created by the installer; create it on the site or skip those cases.
+UNA_TEST_ADMIN_EMAIL=admin@example.com
+UNA_TEST_ADMIN_PASSWORD=unauna
+UNA_TEST_USER_EMAIL=user@example.com
+UNA_TEST_USER_PASSWORD=unauna

+ 23 - 0
tests/README.md

@@ -49,3 +49,26 @@ Alternatively:
 ```
 phing test
 ```
+JUnit report: `logs/junit.xml`.
+
+Unit tests only:
+```
+phing test-units
+```
+or:
+```
+composer test-units
+```
+JUnit report: `logs/junit-units.xml`.
+
+Integration tests only:
+```
+phing test-integration
+```
+or:
+```
+composer test-integration
+```
+JUnit report: `logs/junit-integration.xml`.
+
+Integration tests read `tests/.env` when present (`cp tests/.env.example tests/.env`). Without that file they use installer defaults. Tests skip an account that is not installed.

+ 9 - 0
tests/bootstrap.php

@@ -6,6 +6,13 @@ use PHPUnit\Framework\Attributes\Before;
 define('BX_SKIP_INSTALL_CHECK', 1);
 
 $aPathInfo = pathinfo(__FILE__);
+$sAutoloadPath = $aPathInfo['dirname'] . '/../plugins/autoload.php';
+if (is_readable($sAutoloadPath)) {
+    require_once($sAutoloadPath);
+    if (class_exists(Dotenv\Dotenv::class))
+        Dotenv\Dotenv::createImmutable($aPathInfo['dirname'])->safeLoad();
+}
+
 $sHeaderPath = $aPathInfo['dirname'] . '/../inc/header.inc.php';
 if (!file_exists($sHeaderPath))
     die("Script is not installed\n");
@@ -170,3 +177,5 @@ class BxDolTestCase extends \PHPUnit\Framework\TestCase
 
 require_once dirname(__FILE__) . '/units/modules/boonex/posts/BxPostsTestCase.php';
 require_once dirname(__FILE__) . '/units/modules/boonex/persons/BxPersonsTestCase.php';
+require_once dirname(__FILE__) . '/integration/BxDolIntegrationTestCase.php';
+require_once dirname(__FILE__) . '/integration/account/BxDolAccountTestCase.php';

+ 181 - 0
tests/integration/BxDolIntegrationTestCase.php

@@ -0,0 +1,181 @@
+<?php
+
+/**
+ * Shared helpers for integration tests that talk to a live UNA install.
+ */
+abstract class BxDolIntegrationTestCase extends BxDolTestCase
+{
+    protected $_aAccountAuthBackups;
+    protected $_aLoggedBackup;
+    protected $_aPostBackup;
+    protected $_aCookieBackup;
+
+    protected function setUp(): void
+    {
+        BxDolDb::getInstance()->cleanMemoryAll();
+
+        $this->_aAccountAuthBackups = [];
+        foreach (['admin', 'user'] as $sKind) {
+            $oAccount = BxDolAccount::getInstance($this->bxTestEmail($sKind));
+            if (!$oAccount)
+                continue;
+
+            $aInfo = $oAccount->getInfo();
+            $this->_aAccountAuthBackups[(int)$oAccount->id()] = [
+                'login_attempts' => (int)$aInfo['login_attempts'],
+                'locked' => (int)$aInfo['locked'],
+            ];
+        }
+
+        $this->_aLoggedBackup = [
+            'member' => !empty($GLOBALS['logged']['member']),
+            'admin' => !empty($GLOBALS['logged']['admin']),
+        ];
+        $this->_aPostBackup = $_POST;
+        $this->_aCookieBackup = $_COOKIE;
+    }
+
+    protected function tearDown(): void
+    {
+        $this->bxBecomeGuest();
+        $this->bxRestoreAccountAuth();
+
+        $_POST = $this->_aPostBackup ?? [];
+        $_COOKIE = $this->_aCookieBackup ?? [];
+
+        BxDolForm::unSetObjectInstance('sys_login', 'sys_login');
+        foreach ([
+            'sys_account_create',
+            'sys_account_settings_email',
+            'sys_account_settings_info',
+            'sys_account_settings_del_account',
+        ] as $sDisplay)
+            BxDolForm::unSetObjectInstance('sys_account', $sDisplay);
+
+        $GLOBALS['logged']['member'] = $this->_aLoggedBackup['member'] ?? false;
+        $GLOBALS['logged']['admin'] = $this->_aLoggedBackup['admin'] ?? false;
+    }
+
+    protected function bxAccount(string $sKind = 'admin'): BxDolAccount
+    {
+        $sEmail = $this->bxTestEmail($sKind);
+        $oAccount = BxDolAccount::getInstance($sEmail, true);
+        if (!$oAccount)
+            $this->markTestSkipped('Account ' . $sEmail . ' is not installed.');
+
+        return $oAccount;
+    }
+
+    protected function bxBecomeGuest(): void
+    {
+        $oSession = BxDolSession::getInstance();
+        if ($oSession->getId() || $oSession->getUserId() || isLogged())
+            bx_logout(false);
+
+        unset($_COOKIE['memberID'], $_COOKIE['memberPassword'], $_COOKIE[BX_DOL_SESSION_COOKIE]);
+        $this->bxSetLoggedFlags(0);
+    }
+
+    protected function bxSetLoggedFlags(int $iRole): void
+    {
+        $GLOBALS['logged']['member'] = (bool)($iRole & BX_DOL_ROLE_MEMBER);
+        $GLOBALS['logged']['admin'] = (bool)($iRole & BX_DOL_ROLE_ADMIN);
+    }
+
+    protected function bxSyncSessionCookie(): void
+    {
+        $sId = BxDolSession::getInstance()->getId();
+        if ($sId)
+            $_COOKIE[BX_DOL_SESSION_COOKIE] = $sId;
+        else
+            unset($_COOKIE[BX_DOL_SESSION_COOKIE]);
+    }
+
+    protected function bxLoginAs(string $sKind = 'admin', bool $bRememberMe = false): array
+    {
+        $oAccount = $this->bxAccount($sKind);
+        $aInfo = bx_login($oAccount->id(), $bRememberMe);
+        $this->assertIsArray($aInfo, 'bx_login should return account info.');
+        $this->assertSame((int)$oAccount->id(), (int)BxDolSession::getInstance()->getUserId());
+
+        $this->bxSyncSessionCookie();
+        $this->bxSetLoggedFlags((int)$aInfo['role']);
+
+        return $aInfo;
+    }
+
+    protected function bxLoginAsAdmin(bool $bRememberMe = false): array
+    {
+        return $this->bxLoginAs('admin', $bRememberMe);
+    }
+
+    protected function bxLoginAsUser(bool $bRememberMe = false): array
+    {
+        return $this->bxLoginAs('user', $bRememberMe);
+    }
+
+    protected function bxSubmitLoginForm(string $sEmail, string $sPassword): BxBaseFormLogin
+    {
+        BxDolForm::unSetObjectInstance('sys_login', 'sys_login');
+
+        $_POST = [
+            'ID' => $sEmail,
+            'Password' => $sPassword,
+            'role' => (string)BX_DOL_ROLE_MEMBER,
+            'login' => '1',
+        ];
+
+        $oForm = BxDolForm::getObjectInstance('sys_login', 'sys_login');
+        $this->assertInstanceOf(BxBaseFormLogin::class, $oForm);
+
+        $oForm->aParams['csrf']['disable'] = true;
+        $oForm->initChecker();
+
+        return $oForm;
+    }
+
+    protected function bxTestEmail(string $sKind = 'admin'): string
+    {
+        $this->bxAssertAccountKind($sKind);
+        return $sKind === 'user'
+            ? $this->bxEnv('UNA_TEST_USER_EMAIL', 'user@example.com')
+            : $this->bxEnv('UNA_TEST_ADMIN_EMAIL', 'admin@example.com');
+    }
+
+    protected function bxTestPassword(string $sKind = 'admin'): string
+    {
+        $this->bxAssertAccountKind($sKind);
+        return $sKind === 'user'
+            ? $this->bxEnv('UNA_TEST_USER_PASSWORD', 'unauna')
+            : $this->bxEnv('UNA_TEST_ADMIN_PASSWORD', 'unauna');
+    }
+
+    protected function bxAssertAccountKind(string $sKind): void
+    {
+        if ($sKind !== 'admin' && $sKind !== 'user')
+            $this->fail('Unknown test account kind: ' . $sKind);
+    }
+
+    protected function bxEnv(string $sName, string $sDefault): string
+    {
+        $s = getenv($sName);
+        if ($s === false || $s === '')
+            $s = $_ENV[$sName] ?? $_SERVER[$sName] ?? '';
+        return ($s !== '') ? (string)$s : $sDefault;
+    }
+
+    protected function bxRestoreAccountAuth(): void
+    {
+        if (!$this->_aAccountAuthBackups)
+            return;
+
+        foreach ($this->_aAccountAuthBackups as $iId => $aBackup) {
+            BxDolDb::getInstance()->query("UPDATE `sys_accounts` SET `login_attempts` = :attempts, `locked` = :locked WHERE `id` = :id", [
+                'attempts' => $aBackup['login_attempts'],
+                'locked' => $aBackup['locked'],
+                'id' => $iId,
+            ]);
+            BxDolAccount::getInstance($iId, true);
+        }
+    }
+}

+ 58 - 0
tests/integration/account/AccountLifecycleTest.php

@@ -0,0 +1,58 @@
+<?php
+
+use PHPUnit\Framework\Attributes\Group;
+
+/**
+ * Create an account, change its email and name, then delete it on a live install.
+ */
+#[Group('integration')]
+class AccountLifecycleTest extends BxDolAccountTestCase
+{
+    public function testCreateEditAndDeleteAccount(): void
+    {
+        $aCreated = $this->bxCreateLifecycleAccount();
+        $sEmail = $aCreated['email'];
+        $sEmailNew = 'lifecycle-' . bin2hex(random_bytes(4)) . '-new@example.com';
+        $sNameNew = 'Lifecycle Renamed';
+        $this->_aCreatedEmails[] = $sEmailNew;
+
+        $this->assertTrue(isLogged());
+        $this->assertSame($aCreated['id'], (int)getLoggedId());
+        $this->assertSame($sEmail, $aCreated['account']->getEmail());
+        $this->assertSame($aCreated['name'], $aCreated['account']->getDisplayName());
+
+        $sEmailResult = $this->bxSubmitEmailForm($aCreated['id'], $sEmailNew, $aCreated['password']);
+        $this->assertIsString($sEmailResult);
+        $this->assertStringContainsString(_t('_sys_account_settings_email_successfully_submitted'), $sEmailResult);
+
+        $oAccount = BxDolAccount::getInstance($aCreated['id'], true);
+        $this->assertInstanceOf(BxDolAccount::class, $oAccount);
+        $this->assertSame($sEmailNew, $oAccount->getEmail());
+        $this->assertSame(0, $this->bxAccountIdByEmail($sEmail));
+        $this->assertSame($aCreated['id'], $this->bxAccountIdByEmail($sEmailNew));
+
+        $sNameResult = $this->bxSubmitInfoForm($aCreated['id'], $sNameNew);
+        $this->assertIsString($sNameResult);
+        $this->assertStringContainsString(_t('_sys_account_settings_info_successfully_submitted'), $sNameResult);
+
+        $oAccount = BxDolAccount::getInstance($aCreated['id'], true);
+        $this->assertInstanceOf(BxDolAccount::class, $oAccount);
+        $this->assertSame($sNameNew, $oAccount->getDisplayName());
+        $this->assertSame($sEmailNew, $oAccount->getEmail());
+
+        $oDeleteForm = $this->bxSubmitDeleteForm($oAccount, $aCreated['password']);
+        $this->assertTrue($oDeleteForm->isSubmittedAndValid(), $oDeleteForm->getFormErrors() ?: 'delete account form should accept the posted values');
+
+        $this->assertTrue($oAccount->delete((int)$oDeleteForm->getCleanValue('delete_content') != 0, false));
+        $this->_iCreatedAccountId = 0;
+
+        $this->bxBecomeGuest();
+        $this->assertFalse(BxDolAccount::getInstance($sEmailNew, true));
+        $this->assertSame(0, $this->bxAccountIdByEmail($sEmail));
+        $this->assertSame(0, $this->bxAccountIdByEmail($sEmailNew));
+        $this->assertEmpty(BxDolAccountQuery::getInstance()->getInfoById($aCreated['id']));
+        $this->assertEmpty(BxDolDb::getInstance()->getColumn("SELECT `id` FROM `sys_profiles` WHERE `account_id` = :id", [
+            'id' => $aCreated['id'],
+        ]));
+    }
+}

+ 246 - 0
tests/integration/account/AccountValidationTest.php

@@ -0,0 +1,246 @@
+<?php
+
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\Group;
+
+/**
+ * Account forms reject invalid emails, weak/wrong passwords, and injection payloads.
+ */
+#[Group('integration')]
+class AccountValidationTest extends BxDolAccountTestCase
+{
+    static public function providerForRejectedCreateEmails(): array
+    {
+        return [
+            'empty' => [''],
+            'no-at' => ['not-an-email'],
+            'double-at' => ['a@b@c.com'],
+            'leading-dot' => ['.user@example.com'],
+            'underscore-domain' => ['user@exam_ple.com'],
+            'sql-or' => ["admin@example.com' OR '1'='1"],
+            'sql-comment' => ["' OR 1=1 --"],
+            'sql-union' => ["x' UNION SELECT password FROM sys_accounts --@example.com"],
+            'sql-drop' => ["'; DROP TABLE sys_accounts; --"],
+            'xss' => ['<script>alert(1)</script>@example.com'],
+        ];
+    }
+
+    static public function providerForRejectedCreatePasswords(): array
+    {
+        return [
+            'empty' => [''],
+            'too-short' => ['Ab1'],
+            'no-digit' => ['Password'],
+            'no-upper' => ['password1'],
+            'no-lower' => ['PASSWORD1'],
+            'sql-or' => ["' OR '1'='1"],
+            'sql-comment' => ["' OR 1=1 --"],
+        ];
+    }
+
+    static public function providerForRejectedCreateNames(): array
+    {
+        return [
+            'empty' => [''],
+            'at-prefix' => ['@admin'],
+        ];
+    }
+
+    static public function providerForMaliciousNames(): array
+    {
+        return [
+            'sql-or' => ["' OR '1'='1"],
+            'sql-drop' => ["'; DROP TABLE sys_accounts; --"],
+            'sql-delete' => ['1; DELETE FROM sys_accounts --'],
+            'xss-script' => ['<script>alert(1)</script>'],
+            'xss-img' => ['<img src=x onerror=alert(1)>'],
+        ];
+    }
+
+    static public function providerForRejectedPasswordsCurrent(): array
+    {
+        return [
+            'wrong' => ['not-the-password'],
+            'empty' => [''],
+            'sql-or' => ["' OR '1'='1"],
+            'sql-comment' => ["' OR 1=1 --"],
+            'sql-drop' => ["'; DROP TABLE sys_accounts; --"],
+        ];
+    }
+
+    #[DataProvider('providerForRejectedCreateEmails')]
+    public function testCreateFormRejectsInvalidEmail(string $sEmail): void
+    {
+        $this->bxBecomeGuest();
+        $sName = 'lifecycle-' . bin2hex(random_bytes(4));
+
+        $oForm = $this->bxSubmitCreateForm($sName, $sEmail, 'UnaTest1a');
+        $this->bxAssertFormRejected($oForm, 'email');
+
+        $iCreatedId = $this->bxAccountIdByName($sName);
+        if ($iCreatedId)
+            $this->_iCreatedAccountId = $iCreatedId;
+        $this->assertSame(0, $iCreatedId);
+        $this->bxAssertAccountsTableIntact();
+    }
+
+    #[DataProvider('providerForRejectedCreatePasswords')]
+    public function testCreateFormRejectsInvalidPassword(string $sPassword): void
+    {
+        $this->bxBecomeGuest();
+        $sEmail = 'lifecycle-' . bin2hex(random_bytes(4)) . '@example.com';
+
+        $oForm = $this->bxSubmitCreateForm('Lifecycle User', $sEmail, $sPassword);
+        $this->bxAssertFormRejected($oForm, 'password');
+        $this->assertSame(0, $this->bxAccountIdByEmail($sEmail));
+        $this->bxAssertAccountsTableIntact();
+    }
+
+    #[DataProvider('providerForRejectedCreateNames')]
+    public function testCreateFormRejectsInvalidName(string $sName): void
+    {
+        $this->bxBecomeGuest();
+        $sEmail = 'lifecycle-' . bin2hex(random_bytes(4)) . '@example.com';
+
+        $oForm = $this->bxSubmitCreateForm($sName, $sEmail, 'UnaTest1a');
+        $this->bxAssertFormRejected($oForm, 'name');
+        $this->assertSame(0, $this->bxAccountIdByEmail($sEmail));
+        $this->bxAssertAccountsTableIntact();
+    }
+
+    public function testCreateFormRejectsDuplicateEmail(): void
+    {
+        $this->bxBecomeGuest();
+        $sEmail = $this->bxTestEmail('admin');
+        $iExistingId = $this->bxAccountIdByEmail($sEmail);
+        $this->assertGreaterThan(0, $iExistingId);
+
+        $oForm = $this->bxSubmitCreateForm('Lifecycle User', $sEmail, 'UnaTest1a');
+        $this->bxAssertFormRejected($oForm, 'email');
+        $this->assertSame($iExistingId, $this->bxAccountIdByEmail($sEmail));
+        $this->bxAssertAccountsTableIntact();
+    }
+
+    #[DataProvider('providerForMaliciousNames')]
+    public function testCreateFormTreatsMaliciousNameAsData(string $sName): void
+    {
+        $this->bxBecomeGuest();
+        $sEmail = 'lifecycle-' . bin2hex(random_bytes(4)) . '@example.com';
+
+        $oForm = $this->bxSubmitCreateForm($sName, $sEmail, 'UnaTest1a');
+        if (!$oForm->isSubmittedAndValid()) {
+            $this->assertSame(0, $this->bxAccountIdByEmail($sEmail));
+            $this->bxAssertAccountsTableIntact();
+            return;
+        }
+
+        $this->_aCreatedEmails[] = $sEmail;
+        $iAccountId = $oForm->insert(['email_confirmed' => 0]);
+        $this->assertNotFalse($iAccountId);
+        $this->_iCreatedAccountId = (int)$iAccountId;
+        (new BxTemplAccountForms())->onAccountCreated($iAccountId, $oForm->isSetPendingApproval());
+
+        $this->assertSame((int)$iAccountId, $this->bxAccountIdByEmail($sEmail));
+        $this->bxAssertAccountsTableIntact();
+
+        $oAccount = BxDolAccount::getInstance($iAccountId, true);
+        $this->assertInstanceOf(BxDolAccount::class, $oAccount);
+        $this->assertSame($sEmail, $oAccount->getEmail());
+        $this->assertStringNotContainsString('<script', strtolower($oAccount->getDisplayName()));
+    }
+
+    #[DataProvider('providerForRejectedCreateEmails')]
+    public function testEmailFormRejectsInvalidEmail(string $sEmail): void
+    {
+        $aCreated = $this->bxCreateLifecycleAccount();
+        $sResult = $this->bxSubmitEmailForm($aCreated['id'], $sEmail, $aCreated['password']);
+
+        $this->assertIsString($sResult);
+        $this->assertStringNotContainsString(_t('_sys_account_settings_email_successfully_submitted'), $sResult);
+
+        $oAccount = BxDolAccount::getInstance($aCreated['id'], true);
+        $this->assertSame($aCreated['email'], $oAccount->getEmail());
+        $this->assertSame($aCreated['id'], $this->bxAccountIdByEmail($aCreated['email']));
+        $this->bxAssertAccountsTableIntact();
+    }
+
+    #[DataProvider('providerForRejectedPasswordsCurrent')]
+    public function testEmailFormRejectsWrongPassword(string $sPassword): void
+    {
+        $aCreated = $this->bxCreateLifecycleAccount();
+        $sEmailNew = 'lifecycle-' . bin2hex(random_bytes(4)) . '-new@example.com';
+        $sResult = $this->bxSubmitEmailForm($aCreated['id'], $sEmailNew, $sPassword);
+
+        $this->assertIsString($sResult);
+        $this->assertStringNotContainsString(_t('_sys_account_settings_email_successfully_submitted'), $sResult);
+
+        $oAccount = BxDolAccount::getInstance($aCreated['id'], true);
+        $this->assertSame($aCreated['email'], $oAccount->getEmail());
+        $this->assertSame(0, $this->bxAccountIdByEmail($sEmailNew));
+        $this->bxAssertAccountsTableIntact();
+    }
+
+    public function testEmailFormRejectsDuplicateEmail(): void
+    {
+        $aCreated = $this->bxCreateLifecycleAccount();
+        $sResult = $this->bxSubmitEmailForm($aCreated['id'], $this->bxTestEmail('admin'), $aCreated['password']);
+
+        $this->assertIsString($sResult);
+        $this->assertStringNotContainsString(_t('_sys_account_settings_email_successfully_submitted'), $sResult);
+
+        $oAccount = BxDolAccount::getInstance($aCreated['id'], true);
+        $this->assertSame($aCreated['email'], $oAccount->getEmail());
+        $this->bxAssertAccountsTableIntact();
+    }
+
+    #[DataProvider('providerForRejectedCreateNames')]
+    public function testInfoFormRejectsInvalidName(string $sName): void
+    {
+        $aCreated = $this->bxCreateLifecycleAccount();
+        $sResult = $this->bxSubmitInfoForm($aCreated['id'], $sName);
+
+        $this->assertIsString($sResult);
+        $this->assertStringNotContainsString(_t('_sys_account_settings_info_successfully_submitted'), $sResult);
+
+        $oAccount = BxDolAccount::getInstance($aCreated['id'], true);
+        $this->assertSame($aCreated['name'], $oAccount->getDisplayName());
+        $this->bxAssertAccountsTableIntact();
+    }
+
+    #[DataProvider('providerForMaliciousNames')]
+    public function testInfoFormTreatsMaliciousNameAsData(string $sName): void
+    {
+        $aCreated = $this->bxCreateLifecycleAccount();
+        $sResult = $this->bxSubmitInfoForm($aCreated['id'], $sName);
+        $oAccount = BxDolAccount::getInstance($aCreated['id'], true);
+
+        if (!str_contains((string)$sResult, _t('_sys_account_settings_info_successfully_submitted'))) {
+            $this->assertSame($aCreated['name'], $oAccount->getDisplayName());
+            $this->bxAssertAccountsTableIntact();
+            return;
+        }
+
+        $this->assertSame($aCreated['email'], $oAccount->getEmail());
+        $this->assertStringNotContainsString('<script', strtolower($oAccount->getDisplayName()));
+        $this->bxAssertAccountsTableIntact();
+    }
+
+    #[DataProvider('providerForRejectedPasswordsCurrent')]
+    public function testDeleteFormRejectsWrongPassword(string $sPassword): void
+    {
+        $aCreated = $this->bxCreateLifecycleAccount();
+        $oForm = $this->bxSubmitDeleteForm($aCreated['account'], $sPassword);
+        $this->bxAssertFormRejected($oForm, 'password_current');
+        $this->assertInstanceOf(BxDolAccount::class, BxDolAccount::getInstance($aCreated['id'], true));
+        $this->bxAssertAccountsTableIntact();
+    }
+
+    public function testDeleteFormRejectsMissingConfirm(): void
+    {
+        $aCreated = $this->bxCreateLifecycleAccount();
+        $oForm = $this->bxSubmitDeleteForm($aCreated['account'], $aCreated['password'], '');
+        $this->bxAssertFormRejected($oForm, 'delete_confirm');
+        $this->assertInstanceOf(BxDolAccount::class, BxDolAccount::getInstance($aCreated['id'], true));
+        $this->bxAssertAccountsTableIntact();
+    }
+}

+ 238 - 0
tests/integration/account/BxDolAccountTestCase.php

@@ -0,0 +1,238 @@
+<?php
+
+if (!function_exists('MsgBox'))
+    require_once BX_DIRECTORY_PATH_INC . 'design.inc.php';
+
+/**
+ * Pretend mail was sent so account create/edit does not block on SMTP.
+ */
+class BxTestSkipMailAlertsResponse extends BxDolAlertsResponse
+{
+    public function response($oAlert)
+    {
+        if ($oAlert->sUnit === 'system' && $oAlert->sAction === 'check_send_mail')
+            $oAlert->aExtras['override_result'] = true;
+    }
+}
+
+/**
+ * Shared helpers for account create/edit/delete integration tests.
+ */
+abstract class BxDolAccountTestCase extends BxDolIntegrationTestCase
+{
+    protected $_iCreatedAccountId = 0;
+
+    protected $_aCreatedEmails = [];
+
+    protected $_aAlertsCacheBackup;
+
+    protected $_sConfirmationBackup;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+        $this->_sConfirmationBackup = getParam('sys_account_confirmation_type');
+        $this->bxSetParamCache('sys_account_confirmation_type', BX_ACCOUNT_CONFIRMATION_NONE);
+        $this->bxInstallTestAlertHandlers();
+    }
+
+    protected function tearDown(): void
+    {
+        $this->bxCleanupCreatedAccount();
+        $this->bxRestoreTestAlertHandlers();
+        if ($this->_sConfirmationBackup !== null)
+            $this->bxSetParamCache('sys_account_confirmation_type', $this->_sConfirmationBackup);
+        parent::tearDown();
+    }
+
+    protected function bxAccountForm(string $sDisplay): BxTemplFormAccount
+    {
+        BxDolForm::unSetObjectInstance('sys_account', $sDisplay);
+        $oForm = BxDolForm::getObjectInstance('sys_account', $sDisplay);
+        $this->assertInstanceOf(BxTemplFormAccount::class, $oForm);
+        $oForm->aParams['csrf']['disable'] = true;
+
+        return $oForm;
+    }
+
+    protected function bxSubmitCreateForm(string $sName, string $sEmail, string $sPassword): BxTemplFormAccount
+    {
+        $oForm = $this->bxAccountForm('sys_account_create');
+        $_POST = [
+            'name' => $sName,
+            'email' => $sEmail,
+            'password' => $sPassword,
+            'receive_news' => '1',
+            'do_publish' => '1',
+        ];
+        $oForm->initChecker();
+
+        return $oForm;
+    }
+
+    protected function bxCreateLifecycleAccount(string $sName = 'Lifecycle User', string $sPassword = 'UnaTest1a'): array
+    {
+        $this->bxBecomeGuest();
+
+        $sEmail = 'lifecycle-' . bin2hex(random_bytes(4)) . '@example.com';
+        $this->_aCreatedEmails[] = $sEmail;
+
+        $oForm = $this->bxSubmitCreateForm($sName, $sEmail, $sPassword);
+        $this->assertTrue($oForm->isSubmittedAndValid(), $oForm->getFormErrors() ?: 'create account form should accept the posted values');
+
+        $iAccountId = $oForm->insert(['email_confirmed' => 0]);
+        $this->assertNotFalse($iAccountId);
+        $this->_iCreatedAccountId = (int)$iAccountId;
+
+        BxDolProfileQuery::getInstance()->getInfoById((int)$iAccountId, true);
+        $iProfileId = (new BxTemplAccountForms())->onAccountCreated($iAccountId, $oForm->isSetPendingApproval());
+        $this->assertGreaterThan(0, (int)$iProfileId);
+
+        $oAccount = BxDolAccount::getInstance($iAccountId, true);
+        $this->assertInstanceOf(BxDolAccount::class, $oAccount);
+
+        $aInfo = $oAccount->getInfo();
+        $this->bxSyncSessionCookie();
+        $this->bxSetLoggedFlags((int)$aInfo['role']);
+
+        return [
+            'id' => (int)$iAccountId,
+            'profile_id' => (int)$iProfileId,
+            'email' => $sEmail,
+            'name' => $sName,
+            'password' => $sPassword,
+            'account' => $oAccount,
+        ];
+    }
+
+    protected function bxSubmitEmailForm(int $iAccountId, string $sEmail, string $sPassword): string
+    {
+        $this->bxAccountForm('sys_account_settings_email');
+        $_POST = [
+            'email' => $sEmail,
+            'password_current' => $sPassword,
+            'receive_updates' => '1',
+            'receive_news' => '1',
+            'do_submit' => '1',
+        ];
+
+        return (new BxTemplAccountForms())->editAccountEmailSettingsForm($iAccountId);
+    }
+
+    protected function bxSubmitInfoForm(int $iAccountId, string $sName): string
+    {
+        $this->bxAccountForm('sys_account_settings_info');
+        $_POST = [
+            'name' => $sName,
+            'do_submit' => '1',
+        ];
+
+        return (new BxTemplAccountForms())->editAccountInfoForm($iAccountId);
+    }
+
+    protected function bxSubmitDeleteForm(BxDolAccount $oAccount, string $sPassword, string $sConfirm = '1'): BxTemplFormAccount
+    {
+        $oForm = $this->bxAccountForm('sys_account_settings_del_account');
+        $_POST = [
+            'delete_content' => '0',
+            'delete_confirm' => $sConfirm,
+            'password_current' => $sPassword,
+            'do_submit' => '1',
+        ];
+        $oForm->initChecker($oAccount->getInfo());
+
+        return $oForm;
+    }
+
+    protected function bxAccountIdByEmail(string $sEmail): int
+    {
+        return (int)BxDolAccountQuery::getInstance()->getIdByEmail($sEmail);
+    }
+
+    protected function bxAccountIdByName(string $sName): int
+    {
+        return (int)BxDolDb::getInstance()->getOne('SELECT `id` FROM `sys_accounts` WHERE `name` = :name', [
+            'name' => $sName,
+        ]);
+    }
+
+    protected function bxAssertAccountsTableIntact(): void
+    {
+        $this->assertTrue(BxDolDb::getInstance()->isTableExists('sys_accounts'));
+        $this->assertGreaterThan(0, $this->bxAccountIdByEmail($this->bxTestEmail('admin')));
+    }
+
+    protected function bxAssertFormRejected(BxDolForm $oForm, string $sField = ''): void
+    {
+        $this->assertTrue($oForm->isSubmitted());
+        $this->assertFalse($oForm->isSubmittedAndValid(), $oForm->getFormErrors() ?: 'form should reject the posted values');
+        if ($sField !== '')
+            $this->assertNotEmpty($oForm->aInputs[$sField]['error'] ?? '', $sField . ' should have a checker error');
+    }
+
+    protected function bxSetParamCache(string $sKey, $mixedValue): void
+    {
+        $oProp = new ReflectionProperty(BxDolDb::class, '_aParams');
+        $aParams = $oProp->getValue();
+        $aParams[$sKey] = $mixedValue;
+        $oProp->setValue(null, $aParams);
+    }
+
+    protected function bxInstallTestAlertHandlers(): void
+    {
+        new BxDolAlerts('system', 'phpunit_init', 0, 0);
+
+        $oProp = new ReflectionProperty(BxDolAlerts::class, '_aCacheData');
+        $this->_aAlertsCacheBackup = $oProp->getValue();
+        $aCache = $this->_aAlertsCacheBackup;
+
+        foreach ($aCache['handlers'] as $mixedId => $aHandler) {
+            if (in_array($aHandler['class'] ?? '', ['BxSMTPAlertsResponse', 'BxMapShowAlertsResponse'], true))
+                $aCache['handlers'][$mixedId]['active'] = 0;
+        }
+
+        $sId = 'phpunit_skip_mail';
+        $aCache['handlers'][$sId] = [
+            'name' => $sId,
+            'class' => BxTestSkipMailAlertsResponse::class,
+            'file' => 'tests/integration/account/BxDolAccountTestCase.php',
+            'service_call' => '',
+            'active' => 1,
+        ];
+        $aCache['alerts']['system']['check_send_mail'][] = $sId;
+        $oProp->setValue(null, $aCache);
+    }
+
+    protected function bxRestoreTestAlertHandlers(): void
+    {
+        if ($this->_aAlertsCacheBackup === null)
+            return;
+
+        $oProp = new ReflectionProperty(BxDolAlerts::class, '_aCacheData');
+        $oProp->setValue(null, $this->_aAlertsCacheBackup);
+        $this->_aAlertsCacheBackup = null;
+    }
+
+    protected function bxCleanupCreatedAccount(): void
+    {
+        $aEmails = $this->_aCreatedEmails;
+        if ($this->_iCreatedAccountId)
+            $aEmails[] = $this->_iCreatedAccountId;
+
+        foreach (array_unique($aEmails) as $mixedId) {
+            $oAccount = BxDolAccount::getInstance($mixedId, true);
+            if (!$oAccount)
+                continue;
+
+            $sJob = 'account_delete_' . $oAccount->id();
+            $oJobs = BxDolBackgroundJobs::getInstance();
+            if ($oJobs->exists($sJob))
+                $oJobs->delete($sJob);
+
+            $oAccount->delete(false, false);
+        }
+
+        $this->_iCreatedAccountId = 0;
+        $this->_aCreatedEmails = [];
+    }
+}

+ 124 - 0
tests/integration/login/LoginLogoutTest.php

@@ -0,0 +1,124 @@
+<?php
+
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\Group;
+
+/**
+ * Login and logout against a live install using admin and regular-user accounts.
+ */
+#[Group('integration')]
+class LoginLogoutTest extends BxDolIntegrationTestCase
+{
+    static public function providerForAccounts()
+    {
+        return [
+            'admin' => ['admin'],
+            'user' => ['user'],
+        ];
+    }
+
+    #[DataProvider('providerForAccounts')]
+    public function testValidCredentialsAreAccepted(string $sKind): void
+    {
+        $this->bxAccount($sKind);
+        $this->assertSame('', bx_check_password($this->bxTestEmail($sKind), $this->bxTestPassword($sKind), BX_DOL_ROLE_MEMBER));
+    }
+
+    #[DataProvider('providerForAccounts')]
+    public function testInvalidPasswordIsRejected(string $sKind): void
+    {
+        $this->bxAccount($sKind);
+        $sError = bx_check_password($this->bxTestEmail($sKind), 'not-the-password', BX_DOL_ROLE_MEMBER);
+
+        $this->assertNotSame('', $sError);
+        $this->assertFalse(isLogged());
+        $this->assertSame(0, (int)getLoggedId());
+    }
+
+    #[DataProvider('providerForAccounts')]
+    public function testLoginEstablishesSession(string $sKind): void
+    {
+        $this->assertFalse(isLogged());
+
+        $oAccount = $this->bxAccount($sKind);
+        $aInfo = $this->bxLoginAs($sKind);
+
+        $this->assertSame((int)$oAccount->id(), (int)$aInfo['id']);
+        $this->assertSame($this->bxTestEmail($sKind), $aInfo['email']);
+        $this->assertTrue(isLogged());
+        $this->assertTrue(isMember());
+        $this->assertSame((int)$oAccount->id(), (int)getLoggedId());
+        $this->assertSame((int)$oAccount->id(), (int)BxDolSession::getInstance()->getUserId());
+
+        if ($sKind === 'admin')
+            $this->assertTrue(isAdmin());
+        else
+            $this->assertFalse(isAdmin());
+    }
+
+    #[DataProvider('providerForAccounts')]
+    public function testLogoutClearsSession(string $sKind): void
+    {
+        $this->bxLoginAs($sKind);
+        $this->assertTrue(isLogged());
+
+        bx_logout();
+        $this->bxBecomeGuest();
+
+        $this->assertFalse(isLogged());
+        $this->assertFalse(isAdmin());
+        $this->assertFalse(isMember());
+        $this->assertSame(0, (int)getLoggedId());
+        $this->assertSame(0, (int)BxDolSession::getInstance()->getUserId());
+    }
+
+    #[DataProvider('providerForAccounts')]
+    public function testLoginFormAcceptsCredentials(string $sKind): void
+    {
+        $this->bxAccount($sKind);
+        $oForm = $this->bxSubmitLoginForm($this->bxTestEmail($sKind), $this->bxTestPassword($sKind));
+
+        $this->assertTrue($oForm->isSubmitted());
+        $this->assertTrue($oForm->isSubmittedAndValid(), $oForm->getLoginError() ?: 'login form should accept ' . $sKind . ' credentials');
+
+        $oAccount = BxDolAccount::getInstance(trim($oForm->getCleanValue('ID')));
+        $this->assertInstanceOf(BxDolAccount::class, $oAccount);
+
+        $aInfo = bx_login($oAccount->id(), $oForm->getRememberMe());
+        $this->assertIsArray($aInfo);
+        $this->bxSyncSessionCookie();
+        $this->bxSetLoggedFlags((int)$aInfo['role']);
+
+        $this->assertTrue(isLogged());
+        $this->assertSame((int)$oAccount->id(), (int)getLoggedId());
+        if ($sKind === 'admin')
+            $this->assertTrue(isAdmin());
+        else
+            $this->assertFalse(isAdmin());
+    }
+
+    #[DataProvider('providerForAccounts')]
+    public function testLoginFormRejectsWrongPassword(string $sKind): void
+    {
+        $this->bxAccount($sKind);
+        $oForm = $this->bxSubmitLoginForm($this->bxTestEmail($sKind), 'not-the-password');
+
+        $this->assertTrue($oForm->isSubmitted());
+        $this->assertFalse($oForm->isSubmittedAndValid());
+        $this->assertNotSame('', $oForm->getLoginError());
+        $this->assertFalse(isLogged());
+    }
+
+    #[DataProvider('providerForAccounts')]
+    public function testServiceLogoutEndsSession(string $sKind): void
+    {
+        $this->bxLoginAs($sKind);
+        $this->assertTrue(isLogged());
+
+        $this->assertTrue(bx_srv('system', 'logout', [false], 'TemplServiceContent'));
+        $this->bxBecomeGuest();
+
+        $this->assertFalse(isLogged());
+        $this->assertSame(0, (int)getLoggedId());
+    }
+}

+ 4 - 1
tests/phpunit.xml

@@ -8,9 +8,12 @@
          failOnWarning="false"
          failOnDeprecation="false">
   <testsuites>
-    <testsuite name="AllTests">
+    <testsuite name="Units">
       <directory>./units</directory>
     </testsuite>
+    <testsuite name="Integration">
+      <directory>./integration</directory>
+    </testsuite>
   </testsuites>
   <source>
     <include>

+ 4 - 4
yarn.lock

@@ -2798,10 +2798,10 @@ lucide-static@^1.45.0:
   resolved "https://registry.yarnpkg.com/lucide-static/-/lucide-static-1.45.0.tgz#f244a8a356067996a17abb57e34953888d8a0a12"
   integrity sha512-MaMkcTVobsAlkat8/AsLRgZHeni3/UWHolsAZRazod6/nnBTlhHaS6xlSi+Xh1Y0I1Gf68YHUVXuJe9Up3lZww==
 
-lucide@^1.42.0:
-  version "1.42.0"
-  resolved "https://registry.yarnpkg.com/lucide/-/lucide-1.42.0.tgz#8d09e25b546727c5db9fb8d9ba037f1338ad211e"
-  integrity sha512-KLZ517pIUzuXGSFLlDL+5s6pcp4FI7brn4AXrFmRP7ghtxVVJ7/o4d3VejZeLPU127D/jITLs5WTLnH9Dc7VxA==
+lucide@^1.45.0:
+  version "1.45.0"
+  resolved "https://registry.yarnpkg.com/lucide/-/lucide-1.45.0.tgz#b77e6bd6589a14d0025495a287e197f74a65d3b1"
+  integrity sha512-Paze7TONAGAOSwg2qh2GXY0zkNY6p4+E6BTthwyY6LpVQnqcpV73jtY9UtAZeMfDiZpevZhKINpLu4WeahOXyw==
 
 magic-string@^0.30.3:
   version "0.30.21"