diff --git a/rector.php b/rector.php index 78ede225f..862da0f20 100644 --- a/rector.php +++ b/rector.php @@ -98,14 +98,16 @@ // Ignore tests that use CodeIgniter::CI_VERSION UnwrapFutureCompatibleIfPhpVersionRector::class => [ + __DIR__ . '/src/Test/MockInputOutput.php', + __DIR__ . '/tests/Commands/SetupTest.php', __DIR__ . '/tests/Commands/UserModelGeneratorTest.php', __DIR__ . '/tests/Controllers/LoginTest.php', - __DIR__ . '/tests/Commands/SetupTest.php', ], RemoveUnusedPrivatePropertyRector::class => [ + __DIR__ . '/src/Test/MockInputOutput.php', + __DIR__ . '/tests/Commands/SetupTest.php', __DIR__ . '/tests/Commands/UserModelGeneratorTest.php', __DIR__ . '/tests/Controllers/LoginTest.php', - __DIR__ . '/tests/Commands/SetupTest.php', ], ]); diff --git a/src/Commands/Exceptions/BadInputException.php b/src/Commands/Exceptions/BadInputException.php new file mode 100644 index 000000000..0316394e1 --- /dev/null +++ b/src/Commands/Exceptions/BadInputException.php @@ -0,0 +1,11 @@ + options + + shield:user create -n newusername -e newuser@example.com + + shield:user activate -n username + shield:user activate -e user@example.com + + shield:user deactivate -n username + shield:user deactivate -e user@example.com + + shield:user changename -n username --new-name newusername + shield:user changename -e user@example.com --new-name newusername + + shield:user changeemail -n username --new-email newuseremail@example.com + shield:user changeemail -e user@example.com --new-email newuseremail@example.com + + shield:user delete -i 123 + shield:user delete -n username + shield:user delete -e user@example.com + + shield:user password -n username + shield:user password -e user@example.com + + shield:user list + shield:user list -n username -e user@example.com + + shield:user addgroup -n username -g mygroup + shield:user addgroup -e user@example.com -g mygroup + + shield:user removegroup -n username -g mygroup + shield:user removegroup -e user@example.com -g mygroup + EOL; + + /** + * Command's Arguments + * + * @var array + */ + protected $arguments = [ + 'action' => <<<'EOL' + + create: Create a new user + activate: Activate a user + deactivate: Deactivate a user + changename: Change user name + changeemail: Change user email + delete: Delete a user + password: Change a user password + list: List users + addgroup: Add a user to a group + removegroup: Remove a user from a group + EOL, + ]; + + /** + * Command's Options + * + * @var array + */ + protected $options = [ + '-i' => 'User id', + '-n' => 'User name', + '-e' => 'User email', + '--new-name' => 'New username', + '--new-email' => 'New email', + '-g' => 'Group name', + ]; + + /** + * Validation rules for user fields + */ + private array $validationRules = []; + + /** + * Auth Table names + * + * @var array + */ + private array $tables = []; + + /** + * Displays the help for the spark cli script itself. + */ + public function run(array $params): int + { + $this->ensureInputOutput(); + $this->setTables(); + $this->setValidationRules(); + + $action = $params[0] ?? null; + + if ($action === null || ! in_array($action, $this->validActions, true)) { + $this->write( + 'Specify a valid action: ' . implode(',', $this->validActions), + 'red' + ); + + return EXIT_ERROR; + } + + $userid = (int) ($params['i'] ?? 0); + $username = $params['n'] ?? null; + $email = $params['e'] ?? null; + $newUsername = $params['new-name'] ?? null; + $newEmail = $params['new-email'] ?? null; + $group = $params['g'] ?? null; + + try { + switch ($action) { + case 'create': + $this->create($username, $email); + break; + + case 'activate': + $this->activate($username, $email); + break; + + case 'deactivate': + $this->deactivate($username, $email); + break; + + case 'changename': + $this->changename($username, $email, $newUsername); + break; + + case 'changeemail': + $this->changeemail($username, $email, $newEmail); + break; + + case 'delete': + $this->delete($userid, $username, $email); + break; + + case 'password': + $this->password($username, $email); + break; + + case 'list': + $this->list($username, $email); + break; + + case 'addgroup': + $this->addgroup($group, $username, $email); + break; + + case 'removegroup': + $this->removegroup($group, $username, $email); + break; + } + } catch (BadInputException|CancelException|UserNotFoundException $e) { + $this->write($e->getMessage(), 'red'); + + return EXIT_ERROR; + } + + return EXIT_SUCCESS; + } + + private function setTables(): void + { + /** @var Auth $config */ + $config = config('Auth'); + $this->tables = $config->tables; + } + + private function setValidationRules(): void + { + $validationRules = new RegistrationValidationRules(); + + $rules = $validationRules->get(); + + // Remove `strong_password` because it only supports use cases + // to check the user's own password. + $passwordRules = $rules['password']['rules']; + if (is_string($passwordRules)) { + $passwordRules = explode('|', $passwordRules); + } + if (($key = array_search('strong_password[]', $passwordRules, true)) !== false) { + unset($passwordRules[$key]); + } + + /** @var Auth $config */ + $config = config('Auth'); + + // Add `min_length` + $passwordRules[] = 'min_length[' . $config->minimumPasswordLength . ']'; + + $rules['password']['rules'] = $passwordRules; + + $this->validationRules = [ + 'username' => $rules['username'], + 'email' => $rules['email'], + 'password' => $rules['password'], + ]; + } + + /** + * Asks the user for input. + * + * @param string $field Output "field" question + * @param array|string $options String to a default value, array to a list of options (the first option will be the default value) + * @param array|string $validation Validation rules + * + * @return string The user input + */ + private function prompt(string $field, $options = null, $validation = null): string + { + return self::$io->prompt($field, $options, $validation); + } + + /** + * Outputs a string to the cli on its own line. + */ + private function write( + string $text = '', + ?string $foreground = null, + ?string $background = null + ): void { + self::$io->write($text, $foreground, $background); + } + + /** + * Create a new user + * + * @param string|null $username User name to create (optional) + * @param string|null $email User email to create (optional) + */ + private function create(?string $username = null, ?string $email = null): void + { + $data = []; + + if ($username === null) { + $username = $this->prompt('Username', null, $this->validationRules['username']['rules']); + } + $data['username'] = $username; + + if ($email === null) { + $email = $this->prompt('Email', null, $this->validationRules['email']['rules']); + } + $data['email'] = $email; + + $password = $this->prompt( + 'Password', + null, + $this->validationRules['password']['rules'] + ); + $passwordConfirm = $this->prompt( + 'Password confirmation', + null, + $this->validationRules['password']['rules'] + ); + + if ($password !== $passwordConfirm) { + throw new BadInputException("The passwords don't match"); + } + $data['password'] = $password; + + // Run validation if the user has passed username and/or email via command line + $validation = Services::validation(); + $validation->setRules($this->validationRules); + + if (! $validation->run($data)) { + foreach ($validation->getErrors() as $message) { + $this->write($message, 'red'); + } + + throw new CancelException('User creation aborted'); + } + + $userModel = model(UserModel::class); + + $user = new UserEntity($data); + $userModel->save($user); + + $this->write('User "' . $username . '" created', 'green'); + } + + /** + * Activate an existing user by username or email + * + * @param string|null $username User name to search for (optional) + * @param string|null $email User email to search for (optional) + */ + private function activate(?string $username = null, ?string $email = null): void + { + $user = $this->findUser('Activate user', $username, $email); + + $confirm = $this->prompt('Activate the user ' . $user->username . ' ?', ['y', 'n']); + + if ($confirm === 'y') { + $userModel = model(UserModel::class); + + $user->active = 1; + $userModel->save($user); + + $this->write('User "' . $user->username . '" activated', 'green'); + } else { + $this->write('User "' . $user->username . '" activation cancelled', 'yellow'); + } + } + + /** + * Deactivate an existing user by username or email + * + * @param string|null $username User name to search for (optional) + * @param string|null $email User email to search for (optional) + */ + private function deactivate(?string $username = null, ?string $email = null): void + { + $user = $this->findUser('Deactivate user', $username, $email); + + $confirm = $this->prompt('Deactivate the user "' . $username . '" ?', ['y', 'n']); + + if ($confirm === 'y') { + $userModel = model(UserModel::class); + + $user->active = 0; + $userModel->save($user); + + $this->write('User "' . $user->username . '" deactivated', 'green'); + } else { + $this->write('User "' . $user->username . '" deactivation cancelled', 'yellow'); + } + } + + /** + * Change the name of an existing user by username or email + * + * @param string|null $username User name to search for (optional) + * @param string|null $email User email to search for (optional) + * @param string|null $newUsername User new name (optional) + */ + private function changename( + ?string $username = null, + ?string $email = null, + ?string $newUsername = null + ): void { + $user = $this->findUser('Change username', $username, $email); + + if ($newUsername === null) { + $newUsername = $this->prompt('New username', null, $this->validationRules['username']['rules']); + } else { + // Run validation if the user has passed username and/or email via command line + $validation = Services::validation(); + $validation->setRules([ + 'username' => $this->validationRules['username'], + ]); + + if (! $validation->run(['username' => $newUsername])) { + foreach ($validation->getErrors() as $message) { + $this->write($message, 'red'); + } + + throw new CancelException('User name change aborted'); + } + } + + $userModel = model(UserModel::class); + + $oldUsername = $user->username; + $user->username = $newUsername; + $userModel->save($user); + + $this->write('Username "' . $oldUsername . '" changed to "' . $newUsername . '"', 'green'); + } + + /** + * Change the email of an existing user by username or email + * + * @param string|null $username User name to search for (optional) + * @param string|null $email User email to search for (optional) + * @param string|null $newEmail User new email (optional) + */ + private function changeemail( + ?string $username = null, + ?string $email = null, + ?string $newEmail = null + ): void { + $user = $this->findUser('Change email', $username, $email); + + if ($newEmail === null) { + $newEmail = $this->prompt('New email', null, $this->validationRules['email']['rules']); + } else { + // Run validation if the user has passed username and/or email via command line + $validation = Services::validation(); + $validation->setRules([ + 'email' => $this->validationRules['email'], + ]); + + if (! $validation->run(['email' => $newEmail])) { + foreach ($validation->getErrors() as $message) { + $this->write($message, 'red'); + } + + throw new CancelException('User email change aborted'); + } + } + + $userModel = model(UserModel::class); + + $user->email = $newEmail; + $userModel->save($user); + + $this->write('Email for "' . $user->username . '" changed to ' . $newEmail, 'green'); + } + + /** + * Delete an existing user by username or email + * + * @param int $userid User id to delete (optional) + * @param string|null $username User name to search for (optional) + * @param string|null $email User email to search for (optional) + */ + private function delete(int $userid = 0, ?string $username = null, ?string $email = null): void + { + $userModel = model(UserModel::class); + + if ($userid !== 0) { + $user = $userModel->findById($userid); + + $this->checkUserExists($user); + } else { + $user = $this->findUser('Delete user', $username, $email); + } + + $confirm = $this->prompt( + 'Delete the user "' . $user->username . '" (' . $user->email . ') ?', + ['y', 'n'] + ); + + if ($confirm === 'y') { + $userModel->delete($user->id, true); + + $this->write('User "' . $user->username . '" deleted', 'green'); + } else { + $this->write('User "' . $user->username . '" deletion cancelled', 'yellow'); + } + } + + /** + * @param UserEntity|null $user + */ + private function checkUserExists($user): void + { + if ($user === null) { + throw new UserNotFoundException("User doesn't exist"); + } + } + + /** + * Change the password of an existing user by username or email + * + * @param string|null $username User name to search for (optional) + * @param string|null $email User email to search for (optional) + */ + private function password($username = null, $email = null): void + { + $user = $this->findUser('Change user password', $username, $email); + + $confirm = $this->prompt('Set the password for "' . $user->username . '" ?', ['y', 'n']); + + if ($confirm === 'y') { + $password = $this->prompt( + 'Password', + null, + $this->validationRules['password']['rules'] + ); + $passwordConfirm = $this->prompt( + 'Password confirmation', + null, + $this->validationRules['password']['rules'] + ); + + if ($password !== $passwordConfirm) { + throw new BadInputException("The passwords don't match"); + } + + $userModel = model(UserModel::class); + + $user->password = $password; + $userModel->save($user); + + $this->write('Password for "' . $user->username . '" set', 'green'); + } else { + $this->write('Password setting for "' . $user->username . '" cancelled', 'yellow'); + } + } + + /** + * List users searching by username or email + * + * @param string|null $username User name to search for (optional) + * @param string|null $email User email to search for (optional) + */ + private function list(?string $username = null, ?string $email = null): void + { + $userModel = model(UserModel::class); + $userModel + ->select($this->tables['users'] . '.id as id, username, secret as email') + ->join( + $this->tables['identities'], + $this->tables['users'] . '.id = ' . $this->tables['identities'] . '.user_id', + 'LEFT' + ) + ->groupStart() + ->where($this->tables['identities'] . '.type', Session::ID_TYPE_EMAIL_PASSWORD) + ->orGroupStart() + ->where($this->tables['identities'] . '.type', null) + ->groupEnd() + ->groupEnd() + ->asArray(); + + if ($username !== null) { + $userModel->like('username', $username); + } + if ($email !== null) { + $userModel->like('secret', $email); + } + + $this->write("Id\tUser"); + + foreach ($userModel->findAll() as $user) { + $this->write($user['id'] . "\t" . $user['username'] . ' (' . $user['email'] . ')'); + } + } + + /** + * Add a user by username or email to a group + * + * @param string|null $group Group to add user to + * @param string|null $username User name to search for (optional) + * @param string|null $email User email to search for (optional) + */ + private function addgroup($group = null, $username = null, $email = null): void + { + if ($group === null) { + $group = $this->prompt('Group', null, 'required'); + } + + $user = $this->findUser('Add user to group', $username, $email); + + $confirm = $this->prompt( + 'Add the user "' . $user->username . '" to the group "' . $group . '" ?', + ['y', 'n'] + ); + + if ($confirm === 'y') { + $user->addGroup($group); + + $this->write('User "' . $user->username . '" added to group "' . $group . '"', 'green'); + } else { + $this->write( + 'Addition of the user "' . $user->username . '" to the group "' . $group . '" cancelled', + 'yellow' + ); + } + } + + /** + * Remove a user by username or email from a group + * + * @param string|null $group Group to remove user from + * @param string|null $username User name to search for (optional) + * @param string|null $email User email to search for (optional) + */ + private function removegroup($group = null, $username = null, $email = null): void + { + if ($group === null) { + $group = $this->prompt('Group', null, 'required'); + } + + $user = $this->findUser('Remove user from group', $username, $email); + + $confirm = $this->prompt( + 'Remove the user "' . $user->username . '" from the group "' . $group . '" ?', + ['y', 'n'] + ); + + if ($confirm === 'y') { + $user->removeGroup($group); + + $this->write('User "' . $user->username . '" removed from group "' . $group . '"', 'green'); + } else { + $this->write('Removal of the user "' . $user->username . '" from the group "' . $group . '" cancelled', 'yellow'); + } + } + + /** + * Find an existing user by username or email. + * + * @param string $question Initial question at user prompt + * @param string|null $username User name to search for (optional) + * @param string|null $email User email to search for (optional) + */ + private function findUser($question = '', $username = null, $email = null): UserEntity + { + if ($username === null && $email === null) { + $choice = $this->prompt($question . ' by username or email ?', ['u', 'e']); + + if ($choice === 'u') { + $username = $this->prompt('Username', null, 'required'); + } elseif ($choice === 'e') { + $email = $this->prompt( + 'Email', + null, + 'required' + ); + } + } + + $userModel = model(UserModel::class); + $userModel + ->select($this->tables['users'] . '.id as id, username, secret') + ->join( + $this->tables['identities'], + $this->tables['users'] . '.id = ' . $this->tables['identities'] . '.user_id', + 'LEFT' + ) + ->groupStart() + ->where($this->tables['identities'] . '.type', Session::ID_TYPE_EMAIL_PASSWORD) + ->orGroupStart() + ->where($this->tables['identities'] . '.type', null) + ->groupEnd() + ->groupEnd() + ->asArray(); + + $user = null; + if ($username !== null) { + $user = $userModel->where('username', $username)->first(); + } elseif ($email !== null) { + $user = $userModel->where('secret', $email)->first(); + } + + $this->checkUserExists($user); + + return $userModel->findById($user['id']); + } + + private function ensureInputOutput(): void + { + if (self::$io === null) { + self::$io = new InputOutput(); + } + } + + /** + * @internal Testing purpose only + */ + public static function setInputOutput(InputOutput $io): void + { + self::$io = $io; + } + + /** + * @internal Testing purpose only + */ + public static function resetInputOutput(): void + { + self::$io = null; + } +} diff --git a/src/Commands/Utils/InputOutput.php b/src/Commands/Utils/InputOutput.php new file mode 100644 index 000000000..5541eb7d3 --- /dev/null +++ b/src/Commands/Utils/InputOutput.php @@ -0,0 +1,35 @@ +tables = $authConfig->tables; } /** @@ -177,35 +167,8 @@ protected function getUserEntity(): User */ protected function getValidationRules(): array { - $registrationUsernameRules = array_merge( - config('AuthSession')->usernameValidationRules, - [sprintf('is_unique[%s.username]', $this->tables['users'])] - ); - $registrationEmailRules = array_merge( - config('AuthSession')->emailValidationRules, - [sprintf('is_unique[%s.secret]', $this->tables['identities'])] - ); + $rules = new RegistrationValidationRules(); - return setting('Validation.registration') ?? [ - 'username' => [ - 'label' => 'Auth.username', - 'rules' => $registrationUsernameRules, - ], - 'email' => [ - 'label' => 'Auth.email', - 'rules' => $registrationEmailRules, - ], - 'password' => [ - 'label' => 'Auth.password', - 'rules' => 'required|' . Passwords::getMaxLengthRule() . '|strong_password[]', - 'errors' => [ - 'max_byte' => 'Auth.errorPasswordTooLongBytes', - ], - ], - 'password_confirm' => [ - 'label' => 'Auth.passwordConfirm', - 'rules' => 'required|matches[password]', - ], - ]; + return $rules->get(); } } diff --git a/src/Exceptions/UserNotFoundException.php b/src/Exceptions/UserNotFoundException.php new file mode 100644 index 000000000..485f5c04e --- /dev/null +++ b/src/Exceptions/UserNotFoundException.php @@ -0,0 +1,9 @@ +inputs = $inputs; + } + + /** + * Takes the last output from the output array. + */ + public function getLastOutput(): string + { + return array_pop($this->outputs); + } + + /** + * Takes the first output from the output array. + */ + public function getFirstOutput(): string + { + return array_shift($this->outputs); + } + + /** + * Returns all outputs. + */ + public function getOutputs(): string + { + return implode('', $this->outputs); + } + + public function prompt(string $field, $options = null, $validation = null): string + { + $input = array_shift($this->inputs); + + if (version_compare(CodeIgniter::CI_VERSION, '4.3.0', '>=')) { + CITestStreamFilter::registration(); + CITestStreamFilter::addOutputFilter(); + + PhpStreamWrapper::register(); + PhpStreamWrapper::setContent($input); + + $userInput = CLI::prompt($field, $options, $validation); + + PhpStreamWrapper::restore(); + + CITestStreamFilter::removeOutputFilter(); + CITestStreamFilter::removeErrorFilter(); + + if ($input !== $userInput) { + throw new LogicException($input . '!==' . $userInput); + } + } + + return $input; + } + + public function write( + string $text = '', + ?string $foreground = null, + ?string $background = null + ): void { + if (version_compare(CodeIgniter::CI_VERSION, '4.3.0', '>=')) { + CITestStreamFilter::registration(); + CITestStreamFilter::addOutputFilter(); + } else { + CITestStreamFilter::$buffer = ''; + + $streamFilter = stream_filter_append(STDOUT, 'CITestStreamFilter'); + } + + CLI::write($text, $foreground, $background); + $this->outputs[] = CITestStreamFilter::$buffer; + + if (version_compare(CodeIgniter::CI_VERSION, '4.3.0', '>=')) { + CITestStreamFilter::removeOutputFilter(); + CITestStreamFilter::removeErrorFilter(); + } else { + stream_filter_remove($streamFilter); + } + } +} diff --git a/src/Validation/RegistrationValidationRules.php b/src/Validation/RegistrationValidationRules.php new file mode 100644 index 000000000..d7755d439 --- /dev/null +++ b/src/Validation/RegistrationValidationRules.php @@ -0,0 +1,59 @@ +tables = $authConfig->tables; + } + + public function get(): array + { + $registrationUsernameRules = array_merge( + config('AuthSession')->usernameValidationRules, + [sprintf('is_unique[%s.username]', $this->tables['users'])] + ); + $registrationEmailRules = array_merge( + config('AuthSession')->emailValidationRules, + [sprintf('is_unique[%s.secret]', $this->tables['identities'])] + ); + + helper('setting'); + + return setting('Validation.registration') ?? [ + 'username' => [ + 'label' => 'Auth.username', + 'rules' => $registrationUsernameRules, + ], + 'email' => [ + 'label' => 'Auth.email', + 'rules' => $registrationEmailRules, + ], + 'password' => [ + 'label' => 'Auth.password', + 'rules' => 'required|' . Passwords::getMaxLengthRule() . '|strong_password[]', + 'errors' => [ + 'max_byte' => 'Auth.errorPasswordTooLongBytes', + ], + ], + 'password_confirm' => [ + 'label' => 'Auth.passwordConfirm', + 'rules' => 'required|matches[password]', + ], + ]; + } +} diff --git a/tests/Commands/UserTest.php b/tests/Commands/UserTest.php new file mode 100644 index 000000000..668ef19e2 --- /dev/null +++ b/tests/Commands/UserTest.php @@ -0,0 +1,593 @@ + $inputs User inputs + * @phpstan-param list $inputs + */ + private function setMockIo(array $inputs): void + { + $this->io = new MockInputOutput(); + $this->io->setInputs($inputs); + User::setInputOutput($this->io); + } + + public function testNoAction(): void + { + $this->setMockIo([]); + + command('shield:user'); + + $this->assertStringContainsString( + 'Specify a valid action: create,activate,deactivate,changename,changeemail,delete,password,list,addgroup,removegroup', + $this->io->getLastOutput() + ); + } + + public function testCreate(): void + { + $this->setMockIo([ + 'Secret Passw0rd!', + 'Secret Passw0rd!', + ]); + + command('shield:user create -n user1 -e user1@example.com'); + + $this->assertStringContainsString( + 'User "user1" created', + $this->io->getFirstOutput() + ); + + $users = model(UserModel::class); + $user = $users->findByCredentials(['email' => 'user1@example.com']); + $this->seeInDatabase($this->tables['identities'], [ + 'user_id' => $user->id, + 'secret' => 'user1@example.com', + ]); + $this->seeInDatabase($this->tables['users'], [ + 'id' => $user->id, + 'active' => 0, + ]); + } + + public function testCreateNotUniqueName(): void + { + $user = $this->createUser([ + 'username' => 'user1', + 'email' => 'user1@example.com', + 'password' => 'secret123', + ]); + + $this->setMockIo([ + 'Secret Passw0rd!', + 'Secret Passw0rd!', + ]); + + command('shield:user create -n user1 -e userx@example.com'); + + $this->assertStringContainsString( + 'The Username field must contain a unique value.', + $this->io->getFirstOutput() + ); + $this->assertStringContainsString( + 'User creation aborted', + $this->io->getFirstOutput() + ); + + $users = model(UserModel::class); + $user = $users->findByCredentials(['email' => 'userx@example.com']); + $this->assertNull($user); + } + + public function testCreatePasswordNotMatch(): void + { + $user = $this->createUser([ + 'username' => 'user1', + 'email' => 'user1@example.com', + 'password' => 'secret123', + ]); + + $this->setMockIo([ + 'password', + 'badpassword', + ]); + + command('shield:user create -n user1 -e userx@example.com'); + + $this->assertStringContainsString( + "The passwords don't match", + $this->io->getFirstOutput() + ); + + $users = model(UserModel::class); + $user = $users->findByCredentials(['email' => 'userx@example.com']); + $this->assertNull($user); + } + + /** + * Create an active user. + */ + private function createUser(array $userData): UserEntity + { + /** @var UserEntity $user */ + $user = fake(UserModel::class, ['username' => $userData['username']]); + $user->createEmailIdentity([ + 'email' => $userData['email'], + 'password' => $userData['password'], + ]); + + return $user; + } + + public function testActivate(): void + { + $user = $this->createUser([ + 'username' => 'user2', + 'email' => 'user2@example.com', + 'password' => 'secret123', + ]); + + $user->deactivate(); + $users = model(UserModel::class); + $users->save($user); + + $this->setMockIo(['y']); + + command('shield:user activate -n user2'); + + $this->assertStringContainsString( + 'User "user2" activated', + $this->io->getLastOutput() + ); + + $user = $users->findByCredentials(['email' => 'user2@example.com']); + $this->seeInDatabase($this->tables['users'], [ + 'id' => $user->id, + 'active' => 1, + ]); + } + + public function testDeactivate(): void + { + $this->createUser([ + 'username' => 'user3', + 'email' => 'user3@example.com', + 'password' => 'secret123', + ]); + + $this->setMockIo(['y']); + + command('shield:user deactivate -n user3'); + + $this->assertStringContainsString( + 'User "user3" deactivated', + $this->io->getLastOutput() + ); + + $users = model(UserModel::class); + $user = $users->findByCredentials(['email' => 'user3@example.com']); + $this->seeInDatabase($this->tables['users'], [ + 'id' => $user->id, + 'active' => 0, + ]); + } + + public function testChangename(): void + { + $this->createUser([ + 'username' => 'user4', + 'email' => 'user4@example.com', + 'password' => 'secret123', + ]); + + $this->setMockIo(['y']); + + command('shield:user changename -n user4 --new-name newuser4'); + + $this->assertStringContainsString( + 'Username "user4" changed to "newuser4"', + $this->io->getLastOutput() + ); + + $users = model(UserModel::class); + $user = $users->findByCredentials(['email' => 'user4@example.com']); + $this->seeInDatabase($this->tables['users'], [ + 'id' => $user->id, + 'username' => 'newuser4', + ]); + } + + public function testChangenameInvalidName(): void + { + $this->createUser([ + 'username' => 'user4', + 'email' => 'user4@example.com', + 'password' => 'secret123', + ]); + + $this->setMockIo(['y']); + + command('shield:user changename -n user4 --new-name 1'); + + $this->assertStringContainsString( + 'The Username field must be at least 3 characters in length.', + $this->io->getFirstOutput() + ); + $this->assertStringContainsString( + 'User name change aborted', + $this->io->getFirstOutput() + ); + + $users = model(UserModel::class); + $user = $users->findByCredentials(['email' => 'user4@example.com']); + $this->seeInDatabase($this->tables['users'], [ + 'id' => $user->id, + 'username' => 'user4', + ]); + } + + public function testChangeemail(): void + { + $this->createUser([ + 'username' => 'user5', + 'email' => 'user5@example.com', + 'password' => 'secret123', + ]); + + $this->setMockIo(['y']); + + command('shield:user changeemail -n user5 --new-email newuser5@example.jp'); + + $this->assertStringContainsString( + 'Email for "user5" changed to newuser5@example.jp', + $this->io->getLastOutput() + ); + + $users = model(UserModel::class); + $user = $users->findByCredentials(['email' => 'newuser5@example.jp']); + $this->seeInDatabase($this->tables['users'], [ + 'id' => $user->id, + 'username' => 'user5', + ]); + } + + public function testChangeemailInvalidEmail(): void + { + $this->createUser([ + 'username' => 'user5', + 'email' => 'user5@example.com', + 'password' => 'secret123', + ]); + + $this->setMockIo(['y']); + + command('shield:user changeemail -n user5 --new-email invalid'); + + $this->assertStringContainsString( + 'The Email Address field must contain a valid email address.', + $this->io->getFirstOutput() + ); + $this->assertStringContainsString( + 'User email change aborted', + $this->io->getFirstOutput() + ); + + $users = model(UserModel::class); + $user = $users->findByCredentials(['email' => 'invalid']); + $this->assertNull($user); + } + + public function testDelete(): void + { + $this->createUser([ + 'username' => 'user6', + 'email' => 'user6@example.com', + 'password' => 'secret123', + ]); + + $this->setMockIo(['y']); + + command('shield:user delete -n user6'); + + $this->assertStringContainsString( + 'User "user6" deleted', + $this->io->getLastOutput() + ); + + $users = model(UserModel::class); + $user = $users->findByCredentials(['email' => 'user6@example.com']); + $this->assertNull($user); + } + + public function testDeleteById(): void + { + $user = $this->createUser([ + 'username' => 'user6', + 'email' => 'user6@example.com', + 'password' => 'secret123', + ]); + + $this->setMockIo(['y']); + + command('shield:user delete -i ' . $user->id); + + $this->assertStringContainsString( + 'User "user6" deleted', + $this->io->getLastOutput() + ); + + $users = model(UserModel::class); + $user = $users->findByCredentials(['email' => 'user6@example.com']); + $this->assertNull($user); + } + + public function testDeleteUserNotExist(): void + { + $this->createUser([ + 'username' => 'user6', + 'email' => 'user6@example.com', + 'password' => 'secret123', + ]); + + $this->setMockIo(['y']); + + command('shield:user delete -n userx'); + + $this->assertStringContainsString( + "User doesn't exist", + $this->io->getLastOutput() + ); + + $users = model(UserModel::class); + $user = $users->findByCredentials(['email' => 'user6@example.com']); + $this->assertNotNull($user); + } + + public function testPassword(): void + { + $this->createUser([ + 'username' => 'user7', + 'email' => 'user7@example.com', + 'password' => 'secret123', + ]); + $users = model(UserModel::class); + $user = $users->findByCredentials(['email' => 'user7@example.com']); + $oldPasswordHash = $user->password_hash; + + $this->setMockIo(['y', 'newpassword', 'newpassword']); + + command('shield:user password -n user7'); + + $this->assertStringContainsString( + 'Password for "user7" set', + $this->io->getLastOutput() + ); + + $user = $users->findByCredentials(['email' => 'user7@example.com']); + $this->assertNotSame($oldPasswordHash, $user->password_hash); + } + + public function testPasswordWithoutOptionsAndSpecifyEmail(): void + { + $this->createUser([ + 'username' => 'user7', + 'email' => 'user7@example.com', + 'password' => 'secret123', + ]); + $users = model(UserModel::class); + $user = $users->findByCredentials(['email' => 'user7@example.com']); + $oldPasswordHash = $user->password_hash; + + $this->setMockIo([ + 'e', 'user7@example.com', 'y', 'newpassword', 'newpassword', + ]); + + command('shield:user password'); + + $this->assertStringContainsString( + 'Password for "user7" set', + $this->io->getLastOutput() + ); + + $user = $users->findByCredentials(['email' => 'user7@example.com']); + $this->assertNotSame($oldPasswordHash, $user->password_hash); + } + + public function testPasswordNotMatch(): void + { + $this->createUser([ + 'username' => 'user7', + 'email' => 'user7@example.com', + 'password' => 'secret123', + ]); + $users = model(UserModel::class); + $user = $users->findByCredentials(['email' => 'user7@example.com']); + $oldPasswordHash = $user->password_hash; + + $this->setMockIo([ + 'u', 'user7', 'y', 'newpassword', 'badpassword', + ]); + + command('shield:user password'); + + $this->assertStringContainsString( + "The passwords don't match", + $this->io->getLastOutput() + ); + + $user = $users->findByCredentials(['email' => 'user7@example.com']); + $this->assertSame($oldPasswordHash, $user->password_hash); + } + + public function testList(): void + { + $this->createUser([ + 'username' => 'user8', + 'email' => 'user8@example.com', + 'password' => 'secret123', + ]); + $this->createUser([ + 'username' => 'user9', + 'email' => 'user9@example.com', + 'password' => 'secret123', + ]); + + $this->setMockIo([]); + + command('shield:user list'); + + $this->assertStringContainsString( + 'Id User +1 user8 (user8@example.com) +2 user9 (user9@example.com) +', + $this->io->getOutputs() + ); + } + + public function testListByEmail(): void + { + $this->createUser([ + 'username' => 'user8', + 'email' => 'user8@example.com', + 'password' => 'secret123', + ]); + $this->createUser([ + 'username' => 'user9', + 'email' => 'user9@example.com', + 'password' => 'secret123', + ]); + + $this->setMockIo([]); + + command('shield:user list -e user9@example.com'); + + $this->assertStringContainsString( + 'Id User +2 user9 (user9@example.com) +', + $this->io->getOutputs() + ); + } + + public function testAddgroup(): void + { + $this->createUser([ + 'username' => 'user10', + 'email' => 'user10@example.com', + 'password' => 'secret123', + ]); + + $this->setMockIo(['y']); + + command('shield:user addgroup -n user10 -g admin'); + + $this->assertStringContainsString( + 'User "user10" added to group "admin"', + $this->io->getLastOutput() + ); + + $users = model(UserModel::class); + $user = $users->findByCredentials(['email' => 'user10@example.com']); + $this->assertTrue($user->inGroup('admin')); + } + + public function testAddgroupCancel(): void + { + $this->createUser([ + 'username' => 'user10', + 'email' => 'user10@example.com', + 'password' => 'secret123', + ]); + + $this->setMockIo(['n']); + + command('shield:user addgroup -n user10 -g admin'); + + $this->assertStringContainsString( + 'Addition of the user "user10" to the group "admin" cancelled', + $this->io->getLastOutput() + ); + + $users = model(UserModel::class); + $user = $users->findByCredentials(['email' => 'user10@example.com']); + $this->assertFalse($user->inGroup('admin')); + } + + public function testRemovegroup(): void + { + $this->createUser([ + 'username' => 'user11', + 'email' => 'user11@example.com', + 'password' => 'secret123', + ]); + $users = model(UserModel::class); + $user = $users->findByCredentials(['email' => 'user11@example.com']); + $user->addGroup('admin'); + $this->assertTrue($user->inGroup('admin')); + + $this->setMockIo(['y']); + + command('shield:user removegroup -n user11 -g admin'); + + $this->assertStringContainsString( + 'User "user11" removed from group "admin"', + $this->io->getLastOutput() + ); + + $users = model(UserModel::class); + $user = $users->findByCredentials(['email' => 'user11@example.com']); + $this->assertFalse($user->inGroup('admin')); + } + + public function testRemovegroupCancel(): void + { + $this->createUser([ + 'username' => 'user11', + 'email' => 'user11@example.com', + 'password' => 'secret123', + ]); + $users = model(UserModel::class); + $user = $users->findByCredentials(['email' => 'user11@example.com']); + $user->addGroup('admin'); + $this->assertTrue($user->inGroup('admin')); + + $this->setMockIo(['n']); + + command('shield:user removegroup -n user11 -g admin'); + + $this->assertStringContainsString( + 'Removal of the user "user11" from the group "admin" cancelled', + $this->io->getLastOutput() + ); + + $users = model(UserModel::class); + $user = $users->findByCredentials(['email' => 'user11@example.com']); + $this->assertTrue($user->inGroup('admin')); + } +}