diff --git a/api/controllers/AccountsController.php b/api/controllers/AccountsController.php index 1699e33..76e15c4 100644 --- a/api/controllers/AccountsController.php +++ b/api/controllers/AccountsController.php @@ -2,6 +2,7 @@ namespace api\controllers; use api\models\ChangePasswordForm; +use api\models\ChangeUsernameForm; use common\models\Account; use Yii; use yii\filters\AccessControl; @@ -15,7 +16,7 @@ class AccountsController extends Controller { 'class' => AccessControl::class, 'rules' => [ [ - 'actions' => ['current', 'change-password'], + 'actions' => ['current', 'change-password', 'change-username'], 'allow' => true, 'roles' => ['@'], ], @@ -61,4 +62,19 @@ class AccountsController extends Controller { ]; } + public function actionChangeUsername() { + $model = new ChangeUsernameForm(); + $model->load(Yii::$app->request->post()); + if (!$model->change()) { + return [ + 'success' => false, + 'errors' => $this->normalizeModelErrors($model->getErrors()), + ]; + } + + return [ + 'success' => true, + ]; + } + } diff --git a/api/models/BasePasswordProtectedForm.php b/api/models/BasePasswordProtectedForm.php new file mode 100644 index 0000000..4b5d209 --- /dev/null +++ b/api/models/BasePasswordProtectedForm.php @@ -0,0 +1,30 @@ + 'error.{attribute}_required'], + [['password'], 'validatePassword'], + ]; + } + + public function validatePassword() { + if (!$this->getAccount()->validatePassword($this->password)) { + $this->addError('password', 'error.password_invalid'); + } + } + + /** + * @return \common\models\Account + */ + protected function getAccount() { + return Yii::$app->user->identity; + } + +} diff --git a/api/models/ChangeUsernameForm.php b/api/models/ChangeUsernameForm.php new file mode 100644 index 0000000..413320c --- /dev/null +++ b/api/models/ChangeUsernameForm.php @@ -0,0 +1,38 @@ + 'error.{attribute}_required'], + [['username'], 'validateUsername'], + ]); + } + + public function validateUsername($attribute) { + $account = new Account(); + $account->username = $this->$attribute; + if (!$account->validate(['username'])) { + $account->addErrors($account->getErrors('username')); + } + } + + public function change() { + if (!$this->validate()) { + return false; + } + + $account = $this->getAccount(); + $account->username = $this->username; + + return $account->save(); + } + +} diff --git a/api/models/RegistrationForm.php b/api/models/RegistrationForm.php index bde9c25..1f63da6 100644 --- a/api/models/RegistrationForm.php +++ b/api/models/RegistrationForm.php @@ -19,23 +19,11 @@ class RegistrationForm extends BaseApiForm { public function rules() { return [ - ['rulesAgreement', 'required', 'message' => 'error.you_must_accept_rules'], [[], ReCaptchaValidator::class, 'message' => 'error.captcha_invalid', 'when' => !YII_ENV_TEST], + ['rulesAgreement', 'required', 'message' => 'error.you_must_accept_rules'], - ['username', 'filter', 'filter' => 'trim'], - ['username', 'required', 'message' => 'error.username_required'], - ['username', 'string', 'min' => 3, 'max' => 21, - 'tooShort' => 'error.username_too_short', - 'tooLong' => 'error.username_too_long', - ], - ['username', 'match', 'pattern' => '/^[\p{L}\d-_\.!?#$%^&*()\[\]:;]+$/u'], - ['username', 'unique', 'targetClass' => Account::class, 'message' => 'error.username_not_available'], - - ['email', 'filter', 'filter' => 'trim'], - ['email', 'required', 'message' => 'error.email_required'], - ['email', 'string', 'max' => 255, 'tooLong' => 'error.email_too_long'], - ['email', 'email', 'checkDNS' => true, 'enableIDN' => true, 'message' => 'error.email_invalid'], - ['email', 'unique', 'targetClass' => Account::class, 'message' => 'error.email_not_available'], + ['username', 'validateUsername', 'skipOnEmpty' => false], + ['email', 'validateEmail', 'skipOnEmpty' => false], ['password', 'required', 'message' => 'error.password_required'], ['rePassword', 'required', 'message' => 'error.rePassword_required'], @@ -44,6 +32,22 @@ class RegistrationForm extends BaseApiForm { ]; } + public function validateUsername() { + $account = new Account(); + $account->username = $this->username; + if (!$account->validate(['username'])) { + $this->addErrors($account->getErrors()); + } + } + + public function validateEmail() { + $account = new Account(); + $account->email = $this->email; + if (!$account->validate(['email'])) { + $this->addErrors($account->getErrors()); + } + } + public function validatePasswordAndRePasswordMatch($attribute) { if (!$this->hasErrors()) { if ($this->password !== $this->rePassword) { diff --git a/common/models/Account.php b/common/models/Account.php index f9f0086..f7860a9 100644 --- a/common/models/Account.php +++ b/common/models/Account.php @@ -56,6 +56,22 @@ class Account extends ActiveRecord implements IdentityInterface { public function rules() { return [ + [['username'], 'filter', 'filter' => 'trim'], + [['username'], 'required', 'message' => 'error.username_required'], + [['username'], 'string', 'min' => 3, 'max' => 21, + 'tooShort' => 'error.username_too_short', + 'tooLong' => 'error.username_too_long', + ], + [['username'], 'match', 'pattern' => '/^[\p{L}\d-_\.!?#$%^&*()\[\]:;]+$/u', + 'message' => 'error.username_invalid', + ], + [['username'], 'unique', 'message' => 'error.username_not_available'], + + [['email'], 'filter', 'filter' => 'trim'], + [['email'], 'required', 'message' => 'error.email_required'], + [['email'], 'string', 'max' => 255, 'tooLong' => 'error.email_too_long'], + [['email'], 'email', 'checkDNS' => true, 'enableIDN' => true, 'message' => 'error.email_invalid'], + [['email'], 'unique', 'message' => 'error.email_not_available'], ]; } diff --git a/tests/codeception/api/_pages/AccountsRoute.php b/tests/codeception/api/_pages/AccountsRoute.php index 93b065d..5fe1080 100644 --- a/tests/codeception/api/_pages/AccountsRoute.php +++ b/tests/codeception/api/_pages/AccountsRoute.php @@ -22,4 +22,12 @@ class AccountsRoute extends BasePage { ]); } + public function changeUsername($currentPassword = null, $newUsername = null) { + $this->route = ['accounts/change-username']; + $this->actor->sendPOST($this->getUrl(), [ + 'password' => $currentPassword, + 'username' => $newUsername, + ]); + } + } diff --git a/tests/codeception/api/functional/AccountsChangeUsernameCest.php b/tests/codeception/api/functional/AccountsChangeUsernameCest.php new file mode 100644 index 0000000..e0b7805 --- /dev/null +++ b/tests/codeception/api/functional/AccountsChangeUsernameCest.php @@ -0,0 +1,43 @@ +route = new AccountsRoute($I); + } + + public function _after(FunctionalTester $I) { + /** @var Account $account */ + $account = Account::findOne(1); + $account->username = 'Admin'; + $account->save(); + } + + public function testChangeUsername(FunctionalTester $I, Scenario $scenario) { + $I->wantTo('change my password'); + $I = new AccountSteps($scenario); + $I->loggedInAsActiveAccount(); + + $this->route->changeUsername('password_0', 'bruce_wayne'); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'success' => true, + ]); + } + +} diff --git a/tests/codeception/api/unit/models/BaseApiFormTest.php b/tests/codeception/api/unit/models/BaseApiFormTest.php index cb38bc8..a7ddcad 100644 --- a/tests/codeception/api/unit/models/BaseApiFormTest.php +++ b/tests/codeception/api/unit/models/BaseApiFormTest.php @@ -9,7 +9,7 @@ class BaseApiFormTest extends TestCase { use Specify; public function testLoad() { - $model = new DummyTestModel(); + $model = new DummyBaseApiForm(); $this->specify('model should load data without ModelName array scope', function() use ($model) { expect('model successful load data without prefix', $model->load(['field' => 'test-data']))->true(); expect('field is set as passed data', $model->field)->equals('test-data'); @@ -18,7 +18,7 @@ class BaseApiFormTest extends TestCase { } -class DummyTestModel extends BaseApiForm { +class DummyBaseApiForm extends BaseApiForm { public $field; diff --git a/tests/codeception/api/unit/models/BasePasswordProtectedFormTest.php b/tests/codeception/api/unit/models/BasePasswordProtectedFormTest.php new file mode 100644 index 0000000..d51e1aa --- /dev/null +++ b/tests/codeception/api/unit/models/BasePasswordProtectedFormTest.php @@ -0,0 +1,38 @@ +specify('error.password_invalid on passing invalid account password', function() { + $model = new DummyBasePasswordProtectedForm(); + $model->password = 'some-invalid-password'; + $model->validatePassword(); + expect($model->getErrors('password'))->equals(['error.password_invalid']); + }); + + $this->specify('no errors on passing valid account password', function() { + $model = new DummyBasePasswordProtectedForm(); + $model->password = 'password_0'; + $model->validatePassword(); + expect($model->getErrors('password'))->isEmpty(); + }); + } + +} + +class DummyBasePasswordProtectedForm extends BasePasswordProtectedForm { + + protected function getAccount() { + return new Account([ + 'password' => 'password_0', + ]); + } + +} diff --git a/tests/codeception/api/unit/models/ChangeUsernameFormTest.php b/tests/codeception/api/unit/models/ChangeUsernameFormTest.php new file mode 100644 index 0000000..5fcf2d1 --- /dev/null +++ b/tests/codeception/api/unit/models/ChangeUsernameFormTest.php @@ -0,0 +1,48 @@ + [ + 'class' => AccountFixture::class, + 'dataFile' => '@tests/codeception/common/fixtures/data/accounts.php', + ], + ]; + } + + public function testChange() { + $this->specify('successfully change username to new one', function() { + $model = new DummyChangeUsernameForm([ + 'password' => 'password_0', + 'username' => 'my_new_nickname', + ]); + expect($model->change())->true(); + expect(Account::findOne(1)->username)->equals('my_new_nickname'); + }); + } + +} + +// TODO: тут образуется магическая переменная 1, что не круто. После перехода на php7 можно заюзать анонимный класс +// и создавать модель прямо внутри теста, где доступен объект фикстур с именами переменных + +class DummyChangeUsernameForm extends ChangeUsernameForm { + + protected function getAccount() { + return Account::findOne(1); + } + +} diff --git a/tests/codeception/api/unit/models/RegistrationFormTest.php b/tests/codeception/api/unit/models/RegistrationFormTest.php index 8aac3bf..fbde39d 100644 --- a/tests/codeception/api/unit/models/RegistrationFormTest.php +++ b/tests/codeception/api/unit/models/RegistrationFormTest.php @@ -41,46 +41,27 @@ class RegistrationFormTest extends DbTestCase { ]; } - public function testNotCorrectRegistration() { - $model = new RegistrationForm([ - 'username' => 'valid_nickname', - 'email' => 'correct-email@ely.by', - 'password' => 'enough-length', - 'rePassword' => 'password', - 'rulesAgreement' => true, - ]); - $this->specify('username and email in use, passwords not math - model is not created', function() use ($model) { - expect($model->signup())->null(); - expect($model->getErrors())->notEmpty(); - expect_file($this->getMessageFile())->notExists(); + public function testValidatePasswordAndRePasswordMatch() { + $this->specify('error.rePassword_does_not_match if password and rePassword not match', function() { + $model = new RegistrationForm([ + 'password' => 'enough-length', + 'rePassword' => 'password', + ]); + expect($model->validate(['rePassword']))->false(); + expect($model->getErrors('rePassword'))->equals(['error.rePassword_does_not_match']); + }); + + $this->specify('no errors if password and rePassword match', function() { + $model = new RegistrationForm([ + 'password' => 'enough-length', + 'rePassword' => 'enough-length', + ]); + expect($model->validate(['rePassword']))->true(); + expect($model->getErrors('rePassword'))->isEmpty(); }); } - public function testUsernameValidators() { - $shouldBeValid = [ - 'русский_ник', 'русский_ник_на_грани!', 'numbers1132', '*__*-Stars-*__*', '1-_.!?#$%^&*()[]', '[ESP]Эрик', - 'Свят_помидор;', 'зроблена_ў_беларусі:)', - ]; - $shouldBeInvalid = [ - 'nick@name', 'spaced nick', ' ', 'sh', ' sh ', - ]; - - foreach($shouldBeValid as $nickname) { - $model = new RegistrationForm([ - 'username' => $nickname, - ]); - expect($nickname . ' passed validation', $model->validate(['username']))->true(); - } - - foreach($shouldBeInvalid as $nickname) { - $model = new RegistrationForm([ - 'username' => $nickname, - ]); - expect($nickname . ' fail validation', $model->validate('username'))->false(); - } - } - - public function testCorrectSignup() { + public function testSignup() { $model = new RegistrationForm([ 'username' => 'some_username', 'email' => 'some_email@example.com', @@ -105,6 +86,8 @@ class RegistrationFormTest extends DbTestCase { expect_file('message file exists', $this->getMessageFile())->exists(); } + // TODO: там в самой форме есть метод sendMail(), который рано или поздно должен переехать. К нему нужны будут тоже тесты + private function getMessageFile() { /** @var \yii\swiftmailer\Mailer $mailer */ $mailer = Yii::$app->mailer; diff --git a/tests/codeception/common/unit/models/AccountTest.php b/tests/codeception/common/unit/models/AccountTest.php new file mode 100644 index 0000000..b7f0d8a --- /dev/null +++ b/tests/codeception/common/unit/models/AccountTest.php @@ -0,0 +1,152 @@ + [ + 'class' => AccountFixture::class, + 'dataFile' => '@tests/codeception/common/fixtures/data/accounts.php', + ], + ]; + } + + public function testValidateUsername() { + $this->specify('username required', function() { + $model = new Account(['username' => null]); + expect($model->validate(['username']))->false(); + expect($model->getErrors('username'))->equals(['error.username_required']); + }); + + $this->specify('username should be at least 3 symbols length', function() { + $model = new Account(['username' => 'at']); + expect($model->validate(['username']))->false(); + expect($model->getErrors('username'))->equals(['error.username_too_short']); + }); + + $this->specify('username should be not more than 21 symbols length', function() { + $model = new Account(['username' => 'erickskrauch_erickskrauch']); + expect($model->validate(['username']))->false(); + expect($model->getErrors('username'))->equals(['error.username_too_long']); + }); + + $this->specify('username can contain many cool symbols', function() { + $shouldBeValid = [ + 'русский_ник', 'русский_ник_на_грани!', 'numbers1132', '*__*-Stars-*__*', '1-_.!?#$%^&*()[]', + '[ESP]Эрик', 'Свят_помидор;', 'зроблена_ў_беларусі:)', + ]; + foreach($shouldBeValid as $nickname) { + $model = new Account(['username' => $nickname]); + expect($nickname . ' passed validation', $model->validate(['username']))->true(); + expect($model->getErrors('username'))->isEmpty(); + } + }); + + $this->specify('username cannot contain some symbols', function() { + $shouldBeInvalid = [ + 'nick@name', 'spaced nick', + ]; + foreach($shouldBeInvalid as $nickname) { + $model = new Account(['username' => $nickname]); + expect($nickname . ' fail validation', $model->validate('username'))->false(); + expect($model->getErrors('username'))->equals(['error.username_invalid']); + } + }); + + $this->specify('username should be unique', function() { + $model = new Account(['username' => $this->accounts['admin']['username']]); + expect($model->validate('username'))->false(); + expect($model->getErrors('username'))->equals(['error.username_not_available']); + }); + } + + public function testValidateEmail() { + $this->specify('email required', function() { + $model = new Account(['email' => null]); + expect($model->validate(['email']))->false(); + expect($model->getErrors('email'))->equals(['error.email_required']); + }); + + $this->specify('email should be not more 255 symbols (I hope it\'s impossible to register)', function() { + $model = new Account([ + 'email' => 'emailemailemailemailemailemailemailemailemailemailemailemailemailemailemailemailemail' . + 'emailemailemailemailemailemailemailemailemailemailemailemailemailemailemailemailemail' . + 'emailemailemailemailemailemailemailemailemailemailemailemailemailemailemailemailemail' . + 'emailemail', // = 256 symbols + ]); + expect($model->validate(['email']))->false(); + expect($model->getErrors('email'))->equals(['error.email_too_long']); + }); + + $this->specify('email should be email (it test can fail, if you don\'t have internet connection)', function() { + $model = new Account(['email' => 'invalid_email']); + expect($model->validate(['email']))->false(); + expect($model->getErrors('email'))->equals(['error.email_invalid']); + }); + + $this->specify('email should be unique', function() { + $model = new Account(['email' => $this->accounts['admin']['email']]); + expect($model->validate('email'))->false(); + expect($model->getErrors('email'))->equals(['error.email_not_available']); + }); + } + + public function testSetPassword() { + $this->specify('calling method should change password and set latest password hash algorithm', function() { + $model = new Account(); + $model->setPassword('12345678'); + expect('hash should be set', $model->password_hash)->notEmpty(); + expect('validation should be passed', $model->validatePassword('12345678'))->true(); + expect('latest password hash should be used', $model->password_hash_strategy)->equals(Account::PASS_HASH_STRATEGY_YII2); + }); + } + + public function testValidatePassword() { + $this->specify('old Ely password should work', function() { + $model = new Account([ + 'email' => 'erick@skrauch.net', + 'password_hash' => UserPass::make('erick@skrauch.net', '12345678'), + ]); + expect('valid password should pass', $model->validatePassword('12345678', Account::PASS_HASH_STRATEGY_OLD_ELY))->true(); + expect('invalid password should fail', $model->validatePassword('87654321', Account::PASS_HASH_STRATEGY_OLD_ELY))->false(); + }); + + $this->specify('modern hash algorithm should work', function() { + $model = new Account([ + 'password_hash' => Yii::$app->security->generatePasswordHash('12345678'), + ]); + expect('valid password should pass', $model->validatePassword('12345678', Account::PASS_HASH_STRATEGY_YII2))->true(); + expect('invalid password should fail', $model->validatePassword('87654321', Account::PASS_HASH_STRATEGY_YII2))->false(); + }); + + $this->specify('if second argument is not pass model value should be used', function() { + $model = new Account([ + 'email' => 'erick@skrauch.net', + 'password_hash_strategy' => Account::PASS_HASH_STRATEGY_OLD_ELY, + 'password_hash' => UserPass::make('erick@skrauch.net', '12345678'), + ]); + expect('valid password should pass', $model->validatePassword('12345678'))->true(); + expect('invalid password should fail', $model->validatePassword('87654321'))->false(); + + $model = new Account([ + 'password_hash_strategy' => Account::PASS_HASH_STRATEGY_YII2, + 'password_hash' => Yii::$app->security->generatePasswordHash('12345678'), + ]); + expect('valid password should pass', $model->validatePassword('12345678'))->true(); + expect('invalid password should fail', $model->validatePassword('87654321'))->false(); + }); + } + +}