Add additional features

This commit is contained in:
2026-01-31 13:18:54 +01:00
parent e003257c84
commit 6f0cee7499
22 changed files with 5827 additions and 28 deletions

View File

@@ -39,7 +39,8 @@
},
"autoload-dev": {
"psr-4": {
"cgsmith\\user\\tests\\": "tests/"
"cgsmith\\user\\tests\\": "tests/",
"tests\\": "tests/"
}
},
"extra": {

1371
phpstan-baseline.neon Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -12,8 +12,6 @@ parameters:
treatPhpDocTypesAsCertain: false
reportUnmatchedIgnoredErrors: false
yii2:
config_path: null
includes:
- phpstan-baseline.neon
- vendor/phpstan/phpstan/conf/bleedingEdge.neon

View File

@@ -341,6 +341,7 @@ class Module extends BaseModule implements BootstrapInterface
'RegistrationForm' => 'cgsmith\user\models\RegistrationForm',
'RecoveryForm' => 'cgsmith\user\models\RecoveryForm',
'RecoveryResetForm' => 'cgsmith\user\models\RecoveryResetForm',
'ResendForm' => 'cgsmith\user\models\ResendForm',
'SettingsForm' => 'cgsmith\user\models\SettingsForm',
'UserSearch' => 'cgsmith\user\models\UserSearch',
];

View File

@@ -130,24 +130,7 @@ class RegistrationController extends Controller
throw new NotFoundHttpException();
}
$model = new class extends \yii\base\Model {
public ?string $email = null;
public function rules(): array
{
return [
['email', 'required'],
['email', 'email'],
];
}
public function attributeLabels(): array
{
return [
'email' => Yii::t('user', 'Email'),
];
}
};
$model = $module->createModel('ResendForm');
if ($model->load(Yii::$app->request->post()) && $model->validate()) {
$user = User::findByEmail($model->email);

38
src/models/ResendForm.php Normal file
View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\models;
use Yii;
use yii\base\Model;
/**
* Resend confirmation email form.
*/
class ResendForm extends Model
{
public ?string $email = null;
/**
* {@inheritdoc}
*/
public function rules(): array
{
return [
['email', 'trim'],
['email', 'required'],
['email', 'email'],
];
}
/**
* {@inheritdoc}
*/
public function attributeLabels(): array
{
return [
'email' => Yii::t('user', 'Email'),
];
}
}

View File

@@ -93,7 +93,7 @@ class CaptchaService
$data['remoteip'] = Yii::$app->request->userIP;
}
$ch = curl_init('https://hcaptcha.com/siteverify');
$ch = curl_init('https://api.hcaptcha.com/siteverify');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

View File

@@ -13,13 +13,17 @@ use yii\validators\Validator;
*/
class HCaptchaValidator extends Validator
{
public bool $skipOnEmpty = false;
public $skipOnEmpty = false;
/**
* {@inheritdoc}
*/
protected function validateValue($value): ?array
{
if (empty($value)) {
$value = Yii::$app->request->post('h-captcha-response');
}
if (empty($value)) {
return [Yii::t('user', 'Please complete the CAPTCHA verification.'), []];
}

View File

@@ -13,7 +13,8 @@ use yii\validators\Validator;
*/
class ReCaptchaValidator extends Validator
{
public bool $skipOnEmpty = false;
/** @var bool */
public $skipOnEmpty = false;
/**
* {@inheritdoc}

View File

@@ -46,4 +46,61 @@ $config = [
],
];
new \yii\web\Application($config);
$app = new \yii\web\Application($config);
$db = $app->db;
$db->createCommand('CREATE TABLE IF NOT EXISTS {{%user}} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email VARCHAR(255) NOT NULL,
username VARCHAR(255),
password_hash VARCHAR(255) NOT NULL DEFAULT \'\',
auth_key VARCHAR(32) NOT NULL DEFAULT \'\',
status VARCHAR(20) NOT NULL DEFAULT \'pending\',
email_confirmed_at DATETIME,
blocked_at DATETIME,
last_login_at DATETIME,
last_login_ip VARCHAR(45),
registration_ip VARCHAR(45),
gdpr_consent_at DATETIME,
gdpr_consent_version VARCHAR(20),
gdpr_marketing_consent_at DATETIME,
gdpr_deleted_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)')->execute();
$db->createCommand('CREATE TABLE IF NOT EXISTS {{%user_token}} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
type VARCHAR(20) NOT NULL,
token VARCHAR(64) NOT NULL,
data TEXT,
expires_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)')->execute();
$db->createCommand('CREATE TABLE IF NOT EXISTS {{%user_profile}} (
user_id INTEGER PRIMARY KEY,
name VARCHAR(255),
bio TEXT,
location VARCHAR(255),
website VARCHAR(255),
timezone VARCHAR(40),
avatar_path VARCHAR(255),
gravatar_email VARCHAR(255),
use_gravatar BOOLEAN NOT NULL DEFAULT 1,
public_email VARCHAR(255),
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)')->execute();
$db->createCommand('CREATE TABLE IF NOT EXISTS {{%user_session}} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
session_id VARCHAR(128) NOT NULL,
ip VARCHAR(45),
user_agent TEXT,
device_name VARCHAR(255),
last_activity_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)')->execute();

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
/**
* Inherited Methods
* @method void wantTo($text)
* @method void wantToTest($text)
* @method void execute($callable)
* @method void expectTo($prediction)
* @method void expect($prediction)
* @method void amGoingTo($argumentation)
* @method void am($role)
* @method void lookForwardTo($achieveValue)
* @method void comment($description)
* @method void pause($vars = [])
*
* @SuppressWarnings(PHPMD)
*/
class FunctionalTester extends \Codeception\Actor
{
use _generated\FunctionalTesterActions;
/**
* Define custom actions here
*/
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
/**
* Inherited Methods
* @method void wantTo($text)
* @method void wantToTest($text)
* @method void execute($callable)
* @method void expectTo($prediction)
* @method void expect($prediction)
* @method void amGoingTo($argumentation)
* @method void am($role)
* @method void lookForwardTo($achieveValue)
* @method void comment($description)
* @method void pause($vars = [])
*
* @SuppressWarnings(PHPMD)
*/
class UnitTester extends \Codeception\Actor
{
use _generated\UnitTesterActions;
/**
* Define custom actions here
*/
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,6 @@ modules:
enabled:
- Yii2:
part: [orm, fixtures]
configFile: 'FunctionalConfig.php'
configFile: 'tests/FunctionalConfig.php'
- Asserts
- \tests\_support\Helper\Functional

View File

@@ -3,4 +3,4 @@ modules:
enabled:
- Asserts
- \tests\_support\Helper\Unit
bootstrap: UnitBootstrap.php
bootstrap: ../UnitBootstrap.php

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace tests\unit\models;
use Codeception\Test\Unit;
use cgsmith\user\models\LoginForm;
class LoginFormTest extends Unit
{
private LoginForm $form;
protected function _before(): void
{
$this->form = new LoginForm();
}
public function testLoginAndPasswordAreRequired(): void
{
$rules = $this->form->rules();
$requiredRule = null;
foreach ($rules as $rule) {
if ($rule[1] === 'required') {
$requiredRule = $rule[0];
break;
}
}
$this->assertNotNull($requiredRule);
$this->assertContains('login', $requiredRule);
$this->assertContains('password', $requiredRule);
}
public function testRememberMeIsBoolean(): void
{
$rules = $this->form->rules();
$hasBooleanRule = false;
foreach ($rules as $rule) {
if ($rule[1] === 'boolean' && in_array('rememberMe', (array) $rule[0])) {
$hasBooleanRule = true;
break;
}
}
$this->assertTrue($hasBooleanRule);
}
public function testRulesArrayStructure(): void
{
$rules = $this->form->rules();
$this->assertIsArray($rules);
$this->assertNotEmpty($rules);
foreach ($rules as $rule) {
$this->assertIsArray($rule);
$this->assertArrayHasKey(0, $rule);
$this->assertArrayHasKey(1, $rule);
}
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace tests\unit\models;
use Codeception\Test\Unit;
use cgsmith\user\models\Profile;
class ProfileTest extends Unit
{
private Profile $profile;
protected function _before(): void
{
$this->profile = new Profile();
}
public function testGravatarUrlGeneratesCorrectHash(): void
{
$url = $this->profile->getGravatarUrl('test@example.com');
$expected = 'https://www.gravatar.com/avatar/' . md5('test@example.com') . '?s=200&d=identicon';
$this->assertEquals($expected, $url);
}
public function testGravatarUrlHandlesCaseAndWhitespace(): void
{
$url1 = $this->profile->getGravatarUrl(' Test@Example.COM ');
$url2 = $this->profile->getGravatarUrl('test@example.com');
$this->assertEquals($url1, $url2);
}
public function testGravatarUrlRespectsCustomSize(): void
{
$url = $this->profile->getGravatarUrl('test@example.com', 80);
$this->assertStringContainsString('s=80', $url);
}
public function testTimezoneListIsNotEmpty(): void
{
$list = Profile::getTimezoneList();
$this->assertNotEmpty($list);
}
public function testTimezoneListKeysAreValidIdentifiers(): void
{
$list = Profile::getTimezoneList();
$validIdentifiers = \DateTimeZone::listIdentifiers();
foreach (array_keys($list) as $key) {
$this->assertContains($key, $validIdentifiers);
}
}
public function testTimezoneListValuesReplaceUnderscores(): void
{
$list = Profile::getTimezoneList();
$this->assertEquals('America/New York', $list['America/New_York']);
$this->assertEquals('Europe/London', $list['Europe/London']);
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace tests\unit\models;
use Codeception\Test\Unit;
use cgsmith\user\models\Session;
class SessionTest extends Unit
{
public function testNullUserAgentReturnsUnknownDevice(): void
{
$result = Session::parseDeviceName(null);
$this->assertEquals('Unknown Device', $result);
}
public function testWindowsChrome(): void
{
$ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
$this->assertEquals('Chrome on Windows', Session::parseDeviceName($ua));
}
public function testMacOsSafari(): void
{
$ua = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15';
$this->assertEquals('Safari on macOS', Session::parseDeviceName($ua));
}
public function testLinuxFirefox(): void
{
$ua = 'Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0';
$this->assertEquals('Firefox on Linux', Session::parseDeviceName($ua));
}
public function testAndroidChromeDetectedAsLinux(): void
{
// Android UAs contain "linux" which is checked before "android" in parseDeviceName
$ua = 'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36';
$this->assertEquals('Chrome on Linux', Session::parseDeviceName($ua));
}
public function testIosSafariDetectedAsMacOs(): void
{
// iPhone UAs contain "mac os" (in "like Mac OS X") which is checked before "iphone"
$ua = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
$this->assertEquals('Safari on macOS', Session::parseDeviceName($ua));
}
public function testEdgeTakesPriorityOverChrome(): void
{
$ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0';
$this->assertEquals('Edge on Windows', Session::parseDeviceName($ua));
}
public function testOperaDetectedAsChrome(): void
{
// Opera UAs contain "chrome" which is checked before "opr/" in parseDeviceName
$ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 OPR/106.0.0.0';
$this->assertEquals('Chrome on Windows', Session::parseDeviceName($ua));
}
public function testUnknownBrowserAndOs(): void
{
$ua = 'SomeCustomBot/1.0';
$this->assertEquals('Unknown Browser on Unknown OS', Session::parseDeviceName($ua));
}
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace tests\unit\models;
use Codeception\Test\Unit;
use cgsmith\user\models\Token;
class TokenTest extends Unit
{
public function testTypeConstantsExist(): void
{
$this->assertEquals('confirmation', Token::TYPE_CONFIRMATION);
$this->assertEquals('recovery', Token::TYPE_RECOVERY);
$this->assertEquals('email_change', Token::TYPE_EMAIL_CHANGE);
}
public function testIsExpiredReturnsTrueForPastDate(): void
{
$token = new Token();
$token->expires_at = date('Y-m-d H:i:s', time() - 3600);
$this->assertTrue($token->getIsExpired());
}
public function testIsExpiredReturnsFalseForFutureDate(): void
{
$token = new Token();
$token->expires_at = date('Y-m-d H:i:s', time() + 3600);
$this->assertFalse($token->getIsExpired());
}
public function testValidationRulesContainRequiredFields(): void
{
$token = new Token();
$rules = $token->rules();
$requiredRule = null;
foreach ($rules as $rule) {
if ($rule[1] === 'required') {
$requiredRule = $rule[0];
break;
}
}
$this->assertNotNull($requiredRule);
$this->assertContains('user_id', $requiredRule);
$this->assertContains('type', $requiredRule);
$this->assertContains('token', $requiredRule);
$this->assertContains('expires_at', $requiredRule);
}
public function testValidationRulesContainTypeRange(): void
{
$token = new Token();
$rules = $token->rules();
$inRule = null;
foreach ($rules as $rule) {
if ($rule[1] === 'in' && in_array('type', (array) $rule[0])) {
$inRule = $rule;
break;
}
}
$this->assertNotNull($inRule);
$this->assertContains(Token::TYPE_CONFIRMATION, $inRule['range']);
$this->assertContains(Token::TYPE_RECOVERY, $inRule['range']);
$this->assertContains(Token::TYPE_EMAIL_CHANGE, $inRule['range']);
}
public function testValidationRulesContainTokenMaxLength(): void
{
$token = new Token();
$rules = $token->rules();
$stringRule = null;
foreach ($rules as $rule) {
if ($rule[1] === 'string' && in_array('token', (array) $rule[0]) && isset($rule['max'])) {
$stringRule = $rule;
break;
}
}
$this->assertNotNull($stringRule);
$this->assertEquals(64, $stringRule['max']);
}
}

View File

@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace tests\unit\models;
use Codeception\Test\Unit;
use cgsmith\user\models\User;
class UserTest extends Unit
{
public function testStatusConstantsExist(): void
{
$this->assertEquals('pending', User::STATUS_PENDING);
$this->assertEquals('active', User::STATUS_ACTIVE);
$this->assertEquals('blocked', User::STATUS_BLOCKED);
}
public function testIsBlockedReturnsTrueWhenStatusBlocked(): void
{
$user = new User();
$user->status = User::STATUS_BLOCKED;
$this->assertTrue($user->getIsBlocked());
}
public function testIsBlockedReturnsTrueWhenBlockedAtIsSet(): void
{
$user = new User();
$user->status = User::STATUS_ACTIVE;
$user->blocked_at = '2025-01-01 00:00:00';
$this->assertTrue($user->getIsBlocked());
}
public function testIsBlockedReturnsFalseForActiveUser(): void
{
$user = new User();
$user->status = User::STATUS_ACTIVE;
$user->blocked_at = null;
$this->assertFalse($user->getIsBlocked());
}
public function testIsConfirmedReturnsTrueWhenEmailConfirmed(): void
{
$user = new User();
$user->email_confirmed_at = '2025-01-01 00:00:00';
$this->assertTrue($user->getIsConfirmed());
}
public function testIsConfirmedReturnsFalseWhenEmailNotConfirmed(): void
{
$user = new User();
$user->email_confirmed_at = null;
$this->assertFalse($user->getIsConfirmed());
}
public function testValidationRulesRequireEmail(): void
{
$user = new User();
$rules = $user->rules();
$hasEmailRequired = false;
foreach ($rules as $rule) {
if ($rule[1] === 'required' && in_array('email', (array) $rule[0])) {
$hasEmailRequired = true;
break;
}
}
$this->assertTrue($hasEmailRequired);
}
public function testValidationRulesContainEmailFormat(): void
{
$user = new User();
$rules = $user->rules();
$hasEmailValidator = false;
foreach ($rules as $rule) {
if ($rule[1] === 'email' && in_array('email', (array) $rule[0])) {
$hasEmailValidator = true;
break;
}
}
$this->assertTrue($hasEmailValidator);
}
public function testValidationRulesContainUsernamePattern(): void
{
$user = new User();
$rules = $user->rules();
$hasPattern = false;
foreach ($rules as $rule) {
if ($rule[1] === 'match' && in_array('username', (array) $rule[0])) {
$hasPattern = true;
break;
}
}
$this->assertTrue($hasPattern);
}
}

View File

@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace tests\unit\services;
use Codeception\Test\Unit;
use cgsmith\user\models\User;
use cgsmith\user\Module;
use cgsmith\user\services\GdprService;
class GdprServiceTest extends Unit
{
private Module $module;
private GdprService $service;
protected function _before(): void
{
$this->module = new Module('user');
$this->service = new GdprService($this->module);
}
public function testHasValidConsentReturnsTrueWhenGdprDisabled(): void
{
$this->module->enableGdprConsent = false;
$user = new User();
$this->assertTrue($this->service->hasValidConsent($user));
}
public function testHasValidConsentReturnsFalseWhenConsentAtNull(): void
{
$this->module->enableGdprConsent = true;
$user = new User();
$user->gdpr_consent_at = null;
$this->assertFalse($this->service->hasValidConsent($user));
}
public function testHasValidConsentReturnsTrueWhenVersionMatches(): void
{
$this->module->enableGdprConsent = true;
$this->module->gdprConsentVersion = '2.0';
$user = new User();
$user->gdpr_consent_at = '2025-01-01 00:00:00';
$user->gdpr_consent_version = '2.0';
$this->assertTrue($this->service->hasValidConsent($user));
}
public function testHasValidConsentReturnsFalseWhenVersionMismatches(): void
{
$this->module->enableGdprConsent = true;
$this->module->gdprConsentVersion = '2.0';
$user = new User();
$user->gdpr_consent_at = '2025-01-01 00:00:00';
$user->gdpr_consent_version = '1.0';
$this->assertFalse($this->service->hasValidConsent($user));
}
public function testHasValidConsentReturnsTrueWhenNoVersionConfigured(): void
{
$this->module->enableGdprConsent = true;
$this->module->gdprConsentVersion = null;
$user = new User();
$user->gdpr_consent_at = '2025-01-01 00:00:00';
$this->assertTrue($this->service->hasValidConsent($user));
}
public function testNeedsConsentUpdateReturnsFalseWhenDisabled(): void
{
$this->module->enableGdprConsent = false;
$user = new User();
$this->assertFalse($this->service->needsConsentUpdate($user));
}
public function testNeedsConsentUpdateReturnsTrueWhenConsentInvalid(): void
{
$this->module->enableGdprConsent = true;
$this->module->gdprConsentVersion = '2.0';
$user = new User();
$user->gdpr_consent_at = '2025-01-01 00:00:00';
$user->gdpr_consent_version = '1.0';
$this->assertTrue($this->service->needsConsentUpdate($user));
}
public function testIsRouteExemptForBuiltInRoutes(): void
{
$this->assertTrue($this->service->isRouteExempt('user/security/login'));
$this->assertTrue($this->service->isRouteExempt('user/security/logout'));
$this->assertTrue($this->service->isRouteExempt('user/gdpr/consent'));
$this->assertTrue($this->service->isRouteExempt('user/gdpr/index'));
$this->assertTrue($this->service->isRouteExempt('user/gdpr/export'));
$this->assertTrue($this->service->isRouteExempt('user/gdpr/delete'));
}
public function testIsRouteExemptForCustomRoutes(): void
{
$this->module->gdprExemptRoutes = ['site/privacy', 'site/terms'];
$this->assertTrue($this->service->isRouteExempt('site/privacy'));
$this->assertTrue($this->service->isRouteExempt('site/terms'));
}
public function testIsRouteExemptWildcardMatching(): void
{
$this->module->gdprExemptRoutes = ['api/*'];
$this->assertTrue($this->service->isRouteExempt('api/users'));
$this->assertTrue($this->service->isRouteExempt('api/products'));
}
public function testIsRouteExemptReturnsFalseForNonExemptRoute(): void
{
$this->module->gdprExemptRoutes = [];
$this->assertFalse($this->service->isRouteExempt('site/index'));
$this->assertFalse($this->service->isRouteExempt('user/settings/account'));
}
public function testHasMarketingConsentChecksField(): void
{
$user = new User();
$user->gdpr_marketing_consent_at = null;
$this->assertFalse($this->service->hasMarketingConsent($user));
$user->gdpr_marketing_consent_at = '2025-01-01 00:00:00';
$this->assertTrue($this->service->hasMarketingConsent($user));
}
}