diff --git a/docs/getting_started/install.md b/docs/getting_started/install.md index a8ec9d8d9..c608bd3f3 100644 --- a/docs/getting_started/install.md +++ b/docs/getting_started/install.md @@ -54,9 +54,12 @@ Require it with an explicit version constraint allowing its desired stability. ## Initial Setup +There are a few setup items to do before you can start using Shield in +your project. + ### Command Setup -1. Run the following command. This command handles steps 1-5 of *Manual Setup* and runs the migrations. +1. Run the following command. This command handles steps 1-6 of *Manual Setup*. ```console php spark shield:setup @@ -67,36 +70,8 @@ Require it with an explicit version constraint allowing its desired stability. If you want to customize table names, you must change the table names before running database migrations. See [Customizing Table Names](../customization/table_names.md). -2. Configure **app/Config/Email.php** to allow Shield to send emails with the [Email Class](https://codeigniter.com/user_guide/libraries/email.html). - - ```php - [ + // To check old Email Config file + __DIR__ . '/src/Commands/Setup.php', + ], ]); // auto import fully qualified class names diff --git a/src/Commands/BaseCommand.php b/src/Commands/BaseCommand.php new file mode 100644 index 000000000..2b30856f0 --- /dev/null +++ b/src/Commands/BaseCommand.php @@ -0,0 +1,89 @@ +ensureInputOutput(); + } + + /** + * 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 + */ + protected 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. + */ + protected function write( + string $text = '', + ?string $foreground = null, + ?string $background = null + ): void { + self::$io->write($text, $foreground, $background); + } + + /** + * Outputs an error to the CLI using STDERR instead of STDOUT + */ + protected function error( + string $text, + string $foreground = 'light_red', + ?string $background = null + ): void { + self::$io->error($text, $foreground, $background); + } + + protected 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/Setup.php b/src/Commands/Setup.php index ce052b714..a39e7de10 100644 --- a/src/Commands/Setup.php +++ b/src/Commands/Setup.php @@ -4,22 +4,15 @@ namespace CodeIgniter\Shield\Commands; -use CodeIgniter\CLI\BaseCommand; use CodeIgniter\CLI\CLI; use CodeIgniter\Commands\Database\Migrate; use CodeIgniter\Shield\Commands\Setup\ContentReplacer; +use CodeIgniter\Test\Filters\CITestStreamFilter; +use Config\Email as EmailConfig; use Config\Services; class Setup extends BaseCommand { - /** - * The group the command is lumped under - * when listing commands. - * - * @var string - */ - protected $group = 'Shield'; - /** * The Command's name * @@ -89,6 +82,7 @@ private function publishConfig(): void $this->setupRoutes(); $this->setSecurityCSRF(); + $this->setupEmail(); $this->runMigrations(); } @@ -166,18 +160,18 @@ protected function writeFile(string $file, string $content): void if ( ! $overwrite - && CLI::prompt(" File '{$cleanPath}' already exists in destination. Overwrite?", ['n', 'y']) === 'n' + && $this->prompt(" File '{$cleanPath}' already exists in destination. Overwrite?", ['n', 'y']) === 'n' ) { - CLI::error(" Skipped {$cleanPath}. If you wish to overwrite, please use the '-f' option or reply 'y' to the prompt."); + $this->error(" Skipped {$cleanPath}. If you wish to overwrite, please use the '-f' option or reply 'y' to the prompt."); return; } } if (write_file($path, $content)) { - CLI::write(CLI::color(' Created: ', 'green') . $cleanPath); + $this->write(CLI::color(' Created: ', 'green') . $cleanPath); } else { - CLI::error(" Error creating {$cleanPath}."); + $this->error(" Error creating {$cleanPath}."); } } @@ -195,20 +189,20 @@ protected function add(string $file, string $code, string $pattern, string $repl $output = $this->replacer->add($content, $code, $pattern, $replace); if ($output === true) { - CLI::error(" Skipped {$cleanPath}. It has already been updated."); + $this->error(" Skipped {$cleanPath}. It has already been updated."); return; } if ($output === false) { - CLI::error(" Error checking {$cleanPath}."); + $this->error(" Error checking {$cleanPath}."); return; } if (write_file($path, $output)) { - CLI::write(CLI::color(' Updated: ', 'green') . $cleanPath); + $this->write(CLI::color(' Updated: ', 'green') . $cleanPath); } else { - CLI::error(" Error updating {$cleanPath}."); + $this->error(" Error updating {$cleanPath}."); } } @@ -232,12 +226,12 @@ private function replace(string $file, array $replaces): bool } if (write_file($path, $output)) { - CLI::write(CLI::color(' Updated: ', 'green') . $cleanPath); + $this->write(CLI::color(' Updated: ', 'green') . $cleanPath); return true; } - CLI::error(" Error updating {$cleanPath}."); + $this->error(" Error updating {$cleanPath}."); return false; } @@ -287,7 +281,7 @@ private function setSecurityCSRF(): void $cleanPath = clean_path($path); if (! is_file($path)) { - CLI::error(" Not found file '{$cleanPath}'."); + $this->error(" Not found file '{$cleanPath}'."); return; } @@ -297,35 +291,102 @@ private function setSecurityCSRF(): void // check $csrfProtection = 'session' if ($output === $content) { - CLI::write(CLI::color(' Security Setup: ', 'green') . 'Everything is fine.'); + $this->write(CLI::color(' Security Setup: ', 'green') . 'Everything is fine.'); return; } if (write_file($path, $output)) { - CLI::write(CLI::color(' Updated: ', 'green') . "We have updated file '{$cleanPath}' for security reasons."); + $this->write(CLI::color(' Updated: ', 'green') . "We have updated file '{$cleanPath}' for security reasons."); } else { - CLI::error(" Error updating file '{$cleanPath}'."); + $this->error(" Error updating file '{$cleanPath}'."); + } + } + + private function setupEmail(): void + { + $file = 'Config/Email.php'; + + $path = $this->distPath . $file; + $cleanPath = clean_path($path); + + if (! is_file($path)) { + $this->error(" Not found file '{$cleanPath}'."); + + return; + } + + $config = config(EmailConfig::class); + $fromEmail = (string) $config->fromEmail; // Old Config may return null. + $fromName = (string) $config->fromName; + + if ($fromEmail !== '' && $fromName !== '') { + $this->write(CLI::color(' Email Setup: ', 'green') . 'Everything is fine.'); + + return; + } + + $content = file_get_contents($path); + $output = $content; + + if ($fromEmail === '') { + $set = $this->prompt(' The required Config\Email::$fromEmail is not set. Do you set now?', ['y', 'n']); + + if ($set === 'y') { + // Input from email + $fromEmail = $this->prompt(' What is your email?', null, 'required|valid_email'); + + $pattern = '/^ public .*\$fromEmail\s+= \'\';/mu'; + $replace = ' public string $fromEmail = \'' . $fromEmail . '\';'; + $output = preg_replace($pattern, $replace, $content); + } + } + + if ($fromName === '') { + $set = $this->prompt(' The required Config\Email::$fromName is not set. Do you set now?', ['y', 'n']); + + if ($set === 'y') { + $fromName = $this->prompt(' What is your name?', null, 'required'); + + $pattern = '/^ public .*\$fromName\s+= \'\';/mu'; + $replace = ' public string $fromName = \'' . $fromName . '\';'; + $output = preg_replace($pattern, $replace, $output); + } + } + + if (write_file($path, $output)) { + $this->write(CLI::color(' Updated: ', 'green') . $cleanPath); + } else { + $this->error(" Error updating file '{$cleanPath}'."); } } private function runMigrations(): void { if ( - $this->cliPrompt(' Run `spark migrate --all` now?', ['y', 'n']) === 'n' + $this->prompt(' Run `spark migrate --all` now?', ['y', 'n']) === 'n' ) { return; } $command = new Migrate(Services::logger(), Services::commands()); + + // This is a hack for testing. + // @TODO Remove CITestStreamFilter and refactor when CI 4.5.0 or later is supported. + CITestStreamFilter::registration(); + CITestStreamFilter::addOutputFilter(); + CITestStreamFilter::addErrorFilter(); + $command->run(['all' => null]); - } - /** - * This method is for testing. - */ - protected function cliPrompt(string $field, array $options): string - { - return CLI::prompt($field, $options); + CITestStreamFilter::removeOutputFilter(); + CITestStreamFilter::removeErrorFilter(); + + // Capture the output, and write for testing. + // @TODO Remove CITestStreamFilter and refactor when CI 4.5.0 or later is supported. + $output = CITestStreamFilter::$buffer; + $this->write($output); + + CITestStreamFilter::$buffer = ''; } } diff --git a/src/Commands/User.php b/src/Commands/User.php index 4a2d1b1e1..e5d42daa9 100644 --- a/src/Commands/User.php +++ b/src/Commands/User.php @@ -4,11 +4,9 @@ namespace CodeIgniter\Shield\Commands; -use CodeIgniter\CLI\BaseCommand; use CodeIgniter\Shield\Authentication\Authenticators\Session; use CodeIgniter\Shield\Commands\Exceptions\BadInputException; use CodeIgniter\Shield\Commands\Exceptions\CancelException; -use CodeIgniter\Shield\Commands\Utils\InputOutput; use CodeIgniter\Shield\Config\Auth; use CodeIgniter\Shield\Entities\User as UserEntity; use CodeIgniter\Shield\Exceptions\UserNotFoundException; @@ -18,20 +16,11 @@ class User extends BaseCommand { - private static ?InputOutput $io = null; - private array $validActions = [ + private array $validActions = [ 'create', 'activate', 'deactivate', 'changename', 'changeemail', 'delete', 'password', 'list', 'addgroup', 'removegroup', ]; - /** - * The group the command is lumped under - * when listing commands. - * - * @var string - */ - protected $group = 'Shield'; - /** * Command's name * @@ -137,7 +126,6 @@ class User extends BaseCommand */ public function run(array $params): int { - $this->ensureInputOutput(); $this->setTables(); $this->setValidationRules(); @@ -251,31 +239,6 @@ private function setValidationRules(): void ]; } - /** - * 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 * @@ -692,27 +655,4 @@ private function findUser($question = '', $username = null, $email = null): 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 index 5541eb7d3..5bbfb4474 100644 --- a/src/Commands/Utils/InputOutput.php +++ b/src/Commands/Utils/InputOutput.php @@ -32,4 +32,12 @@ public function write( ): void { CLI::write($text, $foreground, $background); } + + /** + * Outputs an error to the CLI using STDERR instead of STDOUT + */ + public function error(string $text, string $foreground = 'light_red', ?string $background = null): void + { + CLI::error($text, $foreground, $background); + } } diff --git a/src/Test/MockInputOutput.php b/src/Test/MockInputOutput.php index 8d268964a..e4ce93dda 100644 --- a/src/Test/MockInputOutput.php +++ b/src/Test/MockInputOutput.php @@ -53,6 +53,7 @@ public function prompt(string $field, $options = null, $validation = null): stri CITestStreamFilter::registration(); CITestStreamFilter::addOutputFilter(); + CITestStreamFilter::addErrorFilter(); PhpStreamWrapper::register(); PhpStreamWrapper::setContent($input); @@ -83,6 +84,16 @@ public function write( $this->outputs[] = CITestStreamFilter::$buffer; CITestStreamFilter::removeOutputFilter(); + } + + public function error(string $text, string $foreground = 'light_red', ?string $background = null): void + { + CITestStreamFilter::registration(); + CITestStreamFilter::addErrorFilter(); + + CLI::error($text, $foreground, $background); + $this->outputs[] = CITestStreamFilter::$buffer; + CITestStreamFilter::removeErrorFilter(); } } diff --git a/tests/Commands/SetupTest.php b/tests/Commands/SetupTest.php index 3333fac2e..648f0f12e 100644 --- a/tests/Commands/SetupTest.php +++ b/tests/Commands/SetupTest.php @@ -5,7 +5,8 @@ namespace Tests\Commands; use CodeIgniter\Shield\Commands\Setup; -use CodeIgniter\Test\Filters\CITestStreamFilter; +use CodeIgniter\Shield\Test\MockInputOutput; +use Config\Email as EmailConfig; use Config\Services; use org\bovigo\vfs\vfsStream; use Tests\Support\TestCase; @@ -15,38 +16,42 @@ */ final class SetupTest extends TestCase { - protected function setUp(): void - { - parent::setUp(); - - CITestStreamFilter::registration(); - CITestStreamFilter::addOutputFilter(); - } + private ?MockInputOutput $io = null; protected function tearDown(): void { parent::tearDown(); - CITestStreamFilter::removeOutputFilter(); - CITestStreamFilter::removeErrorFilter(); + Setup::resetInputOutput(); + } + + /** + * Set MockInputOutput and user inputs. + * + * @param array $inputs User inputs + * @phpstan-param list $inputs + */ + private function setMockIo(array $inputs): void + { + $this->io = new MockInputOutput(); + $this->io->setInputs($inputs); + Setup::setInputOutput($this->io); } public function testRun(): void { - $root = vfsStream::setup('root'); - vfsStream::copyFromFileSystem( - APPPATH, - $root - ); - $appFolder = $root->url() . '/'; + // Set MockIO and your inputs. + $this->setMockIo([ + 'y', + 'admin@example.com', + 'y', + 'Site Administrator', + 'y', + ]); + + $appFolder = $this->createFilesystem(); - $command = $this->getMockBuilder(Setup::class) - ->setConstructorArgs([Services::logger(), Services::commands()]) - ->onlyMethods(['cliPrompt']) - ->getMock(); - $command - ->method('cliPrompt') - ->willReturn('y'); + $command = new Setup(Services::logger(), Services::commands()); $this->setPrivateProperty($command, 'distPath', $appFolder); @@ -66,7 +71,7 @@ public function testRun(): void $security = file_get_contents($appFolder . 'Config/Security.php'); $this->assertStringContainsString('$csrfProtection = \'session\';', $security); - $result = str_replace(["\033[0;32m", "\033[0m"], '', CITestStreamFilter::$buffer); + $result = $this->getOutputWithoutColorCode(); $this->assertStringContainsString( ' Created: vfs://root/Config/Auth.php @@ -74,7 +79,8 @@ public function testRun(): void Created: vfs://root/Config/AuthToken.php Updated: vfs://root/Controllers/BaseController.php Updated: vfs://root/Config/Routes.php - Updated: We have updated file \'vfs://root/Config/Security.php\' for security reasons.', + Updated: We have updated file \'vfs://root/Config/Security.php\' for security reasons. + Updated: vfs://root/Config/Email.php', $result ); $this->assertStringContainsString( @@ -82,4 +88,59 @@ public function testRun(): void $result ); } + + public function testRunEmailConfigIsFine(): void + { + // Set MockIO and your inputs. + $this->setMockIo(['y']); + + $config = config(EmailConfig::class); + $config->fromEmail = 'admin@example.com'; + $config->fromName = 'Site Admin'; + + $appFolder = $this->createFilesystem(); + + $command = new Setup(Services::logger(), Services::commands()); + + $this->setPrivateProperty($command, 'distPath', $appFolder); + + $command->run([]); + + $result = $this->getOutputWithoutColorCode(); + + $this->assertStringContainsString( + ' Created: vfs://root/Config/Auth.php + Created: vfs://root/Config/AuthGroups.php + Created: vfs://root/Config/AuthToken.php + Updated: vfs://root/Controllers/BaseController.php + Updated: vfs://root/Config/Routes.php + Updated: We have updated file \'vfs://root/Config/Security.php\' for security reasons.', + $result + ); + } + + /** + * @return string app folder path + */ + private function createFilesystem(): string + { + $root = vfsStream::setup('root'); + vfsStream::copyFromFileSystem( + APPPATH, + $root + ); + + return $root->url() . '/'; + } + + private function getOutputWithoutColorCode(): string + { + $output = str_replace(["\033[0;32m", "\033[0m"], '', $this->io->getOutputs()); + + if (is_windows()) { + $output = str_replace("\r\n", "\n", $output); + } + + return $output; + } }