From d07c3343d9a37eee89bd6a06e48ed46594f7aa89 Mon Sep 17 00:00:00 2001 From: Hamza Date: Fri, 11 Sep 2026 14:41:21 +0200 Subject: [PATCH] Feat(provisioning): add occ commands Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Hamza --- appinfo/info.xml | 5 + lib/Command/CreateProvisioning.php | 73 +++++ lib/Command/DeleteProvisioning.php | 77 ++++++ lib/Command/ListProvisionings.php | 67 +++++ lib/Command/ProvisionAccounts.php | 50 ++++ lib/Command/ProvisioningOptions.php | 172 ++++++++++++ lib/Command/UpdateProvisioning.php | 82 ++++++ tests/Unit/Command/CreateProvisioningTest.php | 256 +++++++++++++++++ tests/Unit/Command/DeleteProvisioningTest.php | 103 +++++++ tests/Unit/Command/ListProvisioningsTest.php | 80 ++++++ tests/Unit/Command/ProvisionAccountsTest.php | 40 +++ tests/Unit/Command/UpdateProvisioningTest.php | 257 ++++++++++++++++++ 12 files changed, 1262 insertions(+) create mode 100644 lib/Command/CreateProvisioning.php create mode 100644 lib/Command/DeleteProvisioning.php create mode 100644 lib/Command/ListProvisionings.php create mode 100644 lib/Command/ProvisionAccounts.php create mode 100644 lib/Command/ProvisioningOptions.php create mode 100644 lib/Command/UpdateProvisioning.php create mode 100644 tests/Unit/Command/CreateProvisioningTest.php create mode 100644 tests/Unit/Command/DeleteProvisioningTest.php create mode 100644 tests/Unit/Command/ListProvisioningsTest.php create mode 100644 tests/Unit/Command/ProvisionAccountsTest.php create mode 100644 tests/Unit/Command/UpdateProvisioningTest.php diff --git a/appinfo/info.xml b/appinfo/info.xml index 4c5436d0f2..882e5ab27a 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -90,6 +90,11 @@ Learn more about the Nextcloud Ethical AI Rating [in our blog](https://nextcloud OCA\Mail\Command\InspectMailbox OCA\Mail\Command\ListMailboxes OCA\Mail\Command\PredictImportance + OCA\Mail\Command\CreateProvisioning + OCA\Mail\Command\DeleteProvisioning + OCA\Mail\Command\ListProvisionings + OCA\Mail\Command\ProvisionAccounts + OCA\Mail\Command\UpdateProvisioning OCA\Mail\Command\TestAccount OCA\Mail\Command\SyncAccount OCA\Mail\Command\Thread diff --git a/lib/Command/CreateProvisioning.php b/lib/Command/CreateProvisioning.php new file mode 100644 index 0000000000..e3df090dd7 --- /dev/null +++ b/lib/Command/CreateProvisioning.php @@ -0,0 +1,73 @@ +setName('mail:provisioning:create'); + $this->setDescription('Create a mail account provisioning configuration'); + $this->setHelp(sprintf( + <<<'EOT' + A provisioning configuration creates and maintains the mail account of every + user whose email address matches its domain. Pass * as the domain to match all + users. + + All options are required, except the Sieve, master password and LDAP alias ones. + + %s + + New accounts are provisioned when a user opens Mail. Run + mail:provisioning:apply to provision all users right away. + EOT, + $this->templatesHelp(), + )); + $this->addUsage( + "--provisioning-domain='*' --email-template='%USERID%@example.com'" + . " --imap-user='%USERID%' --imap-host=imap.example.com --imap-port=993 --imap-ssl-mode=ssl" + . " --smtp-user='%USERID%' --smtp-host=smtp.example.com --smtp-port=587 --smtp-ssl-mode=tls" + ); + $this->addProvisioningOptions(); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + try { + $data = $this->buildProvisioningData($input, $output); + } catch (InvalidArgumentException $e) { + $output->writeln('' . $e->getMessage() . ''); + return self::INVALID; + } + + try { + $provisioning = $this->provisioningManager->newProvisioning($data); + } catch (ValidationException $e) { + $output->writeln('Invalid or missing values: ' . implode(', ', array_keys($e->getFields())) . ''); + return self::INVALID; + } + + $output->writeln("Provisioning configuration {$provisioning->getId()} created"); + + return self::SUCCESS; + } +} diff --git a/lib/Command/DeleteProvisioning.php b/lib/Command/DeleteProvisioning.php new file mode 100644 index 0000000000..d8e6d6c2a6 --- /dev/null +++ b/lib/Command/DeleteProvisioning.php @@ -0,0 +1,77 @@ +setName('mail:provisioning:delete'); + $this->setDescription('Delete a mail account provisioning configuration and all accounts provisioned with it'); + $this->setHelp( + <<<'EOT' + Deleting a configuration also deletes every mail account that was provisioned + with it, including the locally cached messages. Mail accounts users created + themselves are not affected. + + Run mail:provisioning:list to look up the id of a configuration. + EOT + ); + $this->addUsage('42 --force'); + $this->addArgument(self::ARGUMENT_ID, InputArgument::REQUIRED, 'Id of the provisioning configuration'); + $this->addOption(self::OPTION_FORCE, 'f', InputOption::VALUE_NONE, 'Delete without asking for confirmation'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $id = filter_var($input->getArgument(self::ARGUMENT_ID), FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]); + if ($id === false) { + $output->writeln('Provisioning configuration id must be a positive integer'); + return self::INVALID; + } + + $provisioning = $this->provisioningManager->getConfigById($id); + if ($provisioning === null) { + $output->writeln("Provisioning configuration $id does not exist"); + return self::FAILURE; + } + + if (!$input->getOption(self::OPTION_FORCE)) { + $question = new ConfirmationQuestion( + "Delete configuration $id and all mail accounts provisioned with it? [y/N] ", + false, + ); + if (!$this->getHelper('question')->ask($input, $output, $question)) { + $output->writeln('Aborted'); + return self::SUCCESS; + } + } + + $this->provisioningManager->deprovision($provisioning); + + $output->writeln("Provisioning configuration $id deleted"); + + return self::SUCCESS; + } +} diff --git a/lib/Command/ListProvisionings.php b/lib/Command/ListProvisionings.php new file mode 100644 index 0000000000..d11448563d --- /dev/null +++ b/lib/Command/ListProvisionings.php @@ -0,0 +1,67 @@ +setName('mail:provisioning:list'); + $this->setDescription('List the mail account provisioning configurations'); + $this->setHelp( + <<<'EOT' + The table shows a summary of every configuration and the ids needed by + mail:provisioning:update and mail:provisioning:delete. + Pass --json to print all values. The master password is never printed. + EOT + ); + $this->addOption(self::OPTION_JSON, null, InputOption::VALUE_NONE, 'Print all values as JSON'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $provisionings = $this->provisioningManager->getConfigs(); + + if ($input->getOption(self::OPTION_JSON)) { + $output->writeln(json_encode($provisionings, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)); + + return self::SUCCESS; + } + + $table = new Table($output); + $table->setHeaders(['Id', 'Domain', 'Email', 'IMAP', 'SMTP', 'Sieve', 'Master password']); + foreach ($provisionings as $provisioning) { + $table->addRow([ + $provisioning->getId(), + $provisioning->getProvisioningDomain(), + $provisioning->getEmailTemplate(), + $provisioning->getImapUser() . '@' . $provisioning->getImapHost() . ':' . $provisioning->getImapPort(), + $provisioning->getSmtpUser() . '@' . $provisioning->getSmtpHost() . ':' . $provisioning->getSmtpPort(), + $provisioning->getSieveEnabled() === true ? $provisioning->getSieveHost() . ':' . $provisioning->getSievePort() : 'no', + $provisioning->getMasterPasswordEnabled() === true ? 'yes' : 'no', + ]); + } + $table->render(); + + return self::SUCCESS; + } +} diff --git a/lib/Command/ProvisionAccounts.php b/lib/Command/ProvisionAccounts.php new file mode 100644 index 0000000000..2685186a87 --- /dev/null +++ b/lib/Command/ProvisionAccounts.php @@ -0,0 +1,50 @@ +setName('mail:provisioning:apply'); + $this->setDescription('Apply the provisioning configurations to all users'); + $this->setHelp( + <<<'EOT' + Walks through all users and creates or updates the mail account of everyone + matching a provisioning configuration, instead of waiting for each user to open + Mail. Users who lost access to Mail lose their provisioned account. Existing + accounts are kept when their owner no longer matches any configuration. + + This takes no options. Configurations are managed with + mail:provisioning:create and mail:provisioning:update. + + The IMAP, SMTP and Sieve password can only be stored while the user is logged + in, so provisioned accounts start syncing once their owner opens Mail. + EOT + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $count = $this->provisioningManager->provision(); + + $output->writeln("Provisioned $count accounts"); + + return self::SUCCESS; + } +} diff --git a/lib/Command/ProvisioningOptions.php b/lib/Command/ProvisioningOptions.php new file mode 100644 index 0000000000..93e5047a93 --- /dev/null +++ b/lib/Command/ProvisioningOptions.php @@ -0,0 +1,172 @@ + + */ +trait ProvisioningOptions { + private const SSL_MODES = ['none', 'ssl', 'tls']; + + private const OPTION_MASTER_PASSWORD = 'master-password'; + private const OPTION_NO_MASTER_PASSWORD = 'no-master-password'; + private const OPTION_NO_SIEVE = 'no-sieve'; + private const OPTION_NO_LDAP_ALIASES = 'no-ldap-aliases'; + + /** Option name => [data key, description] */ + private const FIELDS = [ + 'provisioning-domain' => ['provisioningDomain', 'Email domain to provision, or * for all users'], + 'email-template' => ['emailTemplate', 'Account email, supports %USERID%, %EMAIL% and %LDAP:attribute%'], + 'imap-user' => ['imapUser', 'IMAP login, supports the same placeholders as the email template'], + 'imap-host' => ['imapHost', 'IMAP host'], + 'imap-port' => ['imapPort', 'IMAP port'], + 'imap-ssl-mode' => ['imapSslMode', 'IMAP encryption: none, ssl or tls'], + 'smtp-user' => ['smtpUser', 'SMTP login, supports the same placeholders as the email template'], + 'smtp-host' => ['smtpHost', 'SMTP host'], + 'smtp-port' => ['smtpPort', 'SMTP port'], + 'smtp-ssl-mode' => ['smtpSslMode', 'SMTP encryption: none, ssl or tls'], + 'sieve-user' => ['sieveUser', 'Sieve login, supports the same placeholders as the email template'], + 'sieve-host' => ['sieveHost', 'Sieve host, enables Sieve when set'], + 'sieve-port' => ['sievePort', 'Sieve port, required when Sieve is enabled'], + 'sieve-ssl-mode' => ['sieveSslMode', 'Sieve encryption: none, ssl or tls'], + 'master-user' => ['masterUser', 'Master user suffix appended to the login, e.g. *masteruser'], + 'ldap-aliases-attribute' => ['ldapAliasesAttribute', 'LDAP attribute to read aliases from, enables alias provisioning when set'], + ]; + + private function templatesHelp(): string { + return <<<'EOT' + The account email address and the IMAP, SMTP and Sieve logins are templates. They + may contain %USERID%, %EMAIL% and %LDAP:attribute%, which are replaced with the + values of each provisioned user. + + Unless a master password is set, the login password of the user is used for IMAP, + SMTP and Sieve. It is stored when the user opens Mail and updated whenever it + changes, so an admin never has to know it. + EOT; + } + + private function addProvisioningOptions(): void { + foreach (self::FIELDS as $option => [, $description]) { + $this->addOption($option, null, InputOption::VALUE_REQUIRED, $description); + } + + $this->addOption( + self::OPTION_MASTER_PASSWORD, + null, + InputOption::VALUE_OPTIONAL, + 'Master password used for all accounts instead of the login password. Pass without a value to read it from stdin', + ); + $this->addOption(self::OPTION_NO_MASTER_PASSWORD, null, InputOption::VALUE_NONE, 'Use the login password of each user'); + $this->addOption(self::OPTION_NO_SIEVE, null, InputOption::VALUE_NONE, 'Disable Sieve'); + $this->addOption(self::OPTION_NO_LDAP_ALIASES, null, InputOption::VALUE_NONE, 'Disable alias provisioning from LDAP'); + } + + /** + * @param array $defaults values of the configuration being edited, empty when creating a new one + * @return array + * @throws InvalidArgumentException + */ + private function buildProvisioningData(InputInterface $input, OutputInterface $output, array $defaults = []): array { + $data = $defaults; + foreach (self::FIELDS as $option => [$key]) { + $value = $input->getOption($option); + if ($value !== null) { + $data[$key] = $value; + } + } + + $data['sievePort'] = isset($data['sievePort']) && $data['sievePort'] !== '' ? $data['sievePort'] : null; + foreach (['imapPort', 'smtpPort', 'sievePort'] as $key) { + if (isset($data[$key])) { + $port = filter_var($data[$key], FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 65535]]); + if ($port === false) { + throw new InvalidArgumentException($key . ' must be an integer between 1 and 65535'); + } + $data[$key] = $port; + } + } + + foreach (['imapSslMode', 'smtpSslMode', 'sieveSslMode'] as $key) { + $sslMode = $data[$key] ?? ''; + if ($sslMode !== '' && !in_array($sslMode, self::SSL_MODES, true)) { + throw new InvalidArgumentException($key . ' must be one of ' . implode(', ', self::SSL_MODES)); + } + } + + $data['sieveEnabled'] = $this->resolveToggle( + $input->getOption(self::OPTION_NO_SIEVE), + $input->getOption('sieve-host'), + $defaults['sieveEnabled'] ?? false, + ); + if ($data['sieveEnabled'] && $data['sievePort'] === null) { + throw new InvalidArgumentException('sievePort is required when Sieve is enabled'); + } + $data['ldapAliasesProvisioning'] = $this->resolveToggle( + $input->getOption(self::OPTION_NO_LDAP_ALIASES), + $input->getOption('ldap-aliases-attribute'), + $defaults['ldapAliasesProvisioning'] ?? false, + ); + + if ($input->getOption(self::OPTION_NO_MASTER_PASSWORD)) { + $data['masterPasswordEnabled'] = false; + $data['masterPassword'] = ''; + $data['masterUser'] = ''; + } elseif ($input->hasParameterOption('--' . self::OPTION_MASTER_PASSWORD)) { + $masterPassword = $input->getOption(self::OPTION_MASTER_PASSWORD); + $data['masterPasswordEnabled'] = true; + $data['masterPassword'] = $masterPassword ?? $this->askMasterPassword($input, $output); + } + + return $data; + } + + /** + * A feature is switched on by passing the value it depends on and off by its + * negating flag. Without either the stored state wins. + */ + private function resolveToggle(bool $disable, ?string $value, bool $default): bool { + if ($disable) { + return false; + } + if ($value !== null) { + return $value !== ''; + } + + return $default; + } + + private function askMasterPassword(InputInterface $input, OutputInterface $output): string { + if (!$input->isInteractive()) { + $stream = $input instanceof StreamableInputInterface ? $input->getStream() : null; + $password = fgets($stream ?? STDIN); + if ($password === false) { + throw new InvalidArgumentException('Could not read master password from stdin'); + } + + return rtrim($password, "\r\n"); + } + + $question = new Question('Master password: '); + $question->setHidden(true); + $question->setHiddenFallback(false); + $question->setTrimmable(false); + + return rtrim((string)$this->getHelper('question')->ask($input, $output, $question), "\r\n"); + } +} diff --git a/lib/Command/UpdateProvisioning.php b/lib/Command/UpdateProvisioning.php new file mode 100644 index 0000000000..dcf0bb2d13 --- /dev/null +++ b/lib/Command/UpdateProvisioning.php @@ -0,0 +1,82 @@ +setName('mail:provisioning:update'); + $this->setDescription('Update a mail account provisioning configuration'); + $this->setHelp(sprintf( + <<<'EOT' + Only the values passed as options are changed, everything else keeps the value + it has. Sieve, the master password and LDAP alias provisioning are switched + off with --no-sieve, --no-master-password and --no-ldap-aliases. + + %s + + Run mail:provisioning:list to look up the id of a configuration. + EOT, + $this->templatesHelp(), + )); + $this->addUsage('42 --imap-host=imap.example.com --imap-port=993 --imap-ssl-mode=ssl'); + $this->addArgument(self::ARGUMENT_ID, InputArgument::REQUIRED, 'Id of the provisioning configuration'); + $this->addProvisioningOptions(); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $id = filter_var($input->getArgument(self::ARGUMENT_ID), FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]); + if ($id === false) { + $output->writeln('Provisioning configuration id must be a positive integer'); + return self::INVALID; + } + + $provisioning = $this->provisioningManager->getConfigById($id); + if ($provisioning === null) { + $output->writeln("Provisioning configuration $id does not exist"); + return self::FAILURE; + } + + try { + $data = $this->buildProvisioningData($input, $output, $provisioning->jsonSerialize()); + } catch (InvalidArgumentException $e) { + $output->writeln('' . $e->getMessage() . ''); + return self::INVALID; + } + + try { + $this->provisioningManager->updateProvisioning($data); + } catch (ValidationException $e) { + $output->writeln('Invalid or missing values: ' . implode(', ', array_keys($e->getFields())) . ''); + return self::INVALID; + } + + $output->writeln("Provisioning configuration $id updated"); + + return self::SUCCESS; + } +} diff --git a/tests/Unit/Command/CreateProvisioningTest.php b/tests/Unit/Command/CreateProvisioningTest.php new file mode 100644 index 0000000000..9e2c61b026 --- /dev/null +++ b/tests/Unit/Command/CreateProvisioningTest.php @@ -0,0 +1,256 @@ + '*', + '--email-template' => '%USERID%@example.com', + '--imap-user' => '%USERID%', + '--imap-host' => 'imap.example.com', + '--imap-port' => '993', + '--imap-ssl-mode' => 'ssl', + '--smtp-user' => '%USERID%', + '--smtp-host' => 'smtp.example.com', + '--smtp-port' => '587', + '--smtp-ssl-mode' => 'tls', + ]; + + protected function setUp(): void { + parent::setUp(); + + $this->provisioningManager = $this->createMock(ProvisioningManager::class); + $command = new CreateProvisioning($this->provisioningManager); + $command->setHelperSet(new HelperSet([new QuestionHelper()])); + $this->tester = new CommandTester($command); + } + + public function testCreate(): void { + $provisioning = new Provisioning(); + $provisioning->setId(3); + $this->provisioningManager->expects(self::once()) + ->method('newProvisioning') + ->with([ + 'provisioningDomain' => '*', + 'emailTemplate' => '%USERID%@example.com', + 'imapUser' => '%USERID%', + 'imapHost' => 'imap.example.com', + 'imapPort' => 993, + 'imapSslMode' => 'ssl', + 'smtpUser' => '%USERID%', + 'smtpHost' => 'smtp.example.com', + 'smtpPort' => 587, + 'smtpSslMode' => 'tls', + 'sievePort' => null, + 'sieveEnabled' => false, + 'ldapAliasesProvisioning' => false, + ]) + ->willReturn($provisioning); + + $status = $this->tester->execute($this->options); + + self::assertSame(Command::SUCCESS, $status); + self::assertStringContainsString('3', $this->tester->getDisplay()); + } + + public function testCreateWithSieveAndLdapAliases(): void { + $this->provisioningManager->expects(self::once()) + ->method('newProvisioning') + ->with(self::callback(static function (array $data): bool { + return $data['sieveEnabled'] === true + && $data['sieveHost'] === 'sieve.example.com' + && $data['sievePort'] === 4190 + && $data['ldapAliasesProvisioning'] === true + && $data['ldapAliasesAttribute'] === 'proxyAddresses'; + })) + ->willReturn(new Provisioning()); + + $status = $this->tester->execute(array_merge($this->options, [ + '--sieve-host' => 'sieve.example.com', + '--sieve-port' => '4190', + '--sieve-ssl-mode' => 'tls', + '--ldap-aliases-attribute' => 'proxyAddresses', + ])); + + self::assertSame(Command::SUCCESS, $status); + } + + public function testCreateWithMasterPassword(): void { + $this->provisioningManager->expects(self::once()) + ->method('newProvisioning') + ->with(self::callback(static function (array $data): bool { + return $data['masterPasswordEnabled'] === true + && $data['masterPassword'] === 'sesame' + && $data['masterUser'] === '*masteruser'; + })) + ->willReturn(new Provisioning()); + + $status = $this->tester->execute(array_merge($this->options, [ + '--master-password' => 'sesame', + '--master-user' => '*masteruser', + ])); + + self::assertSame(Command::SUCCESS, $status); + } + + public function testCreateWithoutMasterPassword(): void { + $this->provisioningManager->expects(self::once()) + ->method('newProvisioning') + ->with(self::callback(static function (array $data): bool { + return !isset($data['masterPasswordEnabled']); + })) + ->willReturn(new Provisioning()); + + $status = $this->tester->execute($this->options); + + self::assertSame(Command::SUCCESS, $status); + } + + /** @dataProvider passwordInputs */ + public function testReadsMasterPassword(bool $interactive, string $input, string $expected): void { + $this->provisioningManager->expects(self::once()) + ->method('newProvisioning') + ->with(self::callback(static fn (array $data): bool => $data['masterPasswordEnabled'] === true + && $data['masterPassword'] === $expected)) + ->willReturn(new Provisioning()); + + $this->tester->setInputs([$input]); + $status = $this->tester->execute($this->options + ['--master-password' => null], ['interactive' => $interactive]); + + self::assertSame(Command::SUCCESS, $status); + self::assertStringNotContainsString($expected, $this->tester->getDisplay()); + } + + public static function passwordInputs(): array { + return [ + 'interactive' => [true, 'sesame', 'sesame'], + 'interactive whitespace' => [true, ' sesame ', ' sesame '], + 'interactive tabs' => [true, "\tsesame\t", "\tsesame\t"], + 'non-interactive' => [false, 'sesame', 'sesame'], + 'non-interactive whitespace' => [false, ' sesame ', ' sesame '], + 'non-interactive CRLF' => [false, " sesame \r", ' sesame '], + ]; + } + + public function testRejectsMissingPasswordInput(): void { + $this->provisioningManager->expects(self::never()) + ->method('newProvisioning'); + + $status = $this->tester->execute($this->options + ['--master-password' => null], ['interactive' => false]); + + self::assertSame(Command::INVALID, $status); + self::assertStringContainsString('Could not read master password', $this->tester->getDisplay()); + } + + /** @dataProvider invalidPorts */ + public function testRejectsInvalidPorts(string $option, string $port): void { + $this->provisioningManager->expects(self::never()) + ->method('newProvisioning'); + + $status = $this->tester->execute(array_merge($this->options, [$option => $port])); + + self::assertSame(Command::INVALID, $status); + self::assertStringContainsString('must be an integer between 1 and 65535', $this->tester->getDisplay()); + } + + public static function invalidPorts(): array { + $cases = []; + foreach (['--imap-port', '--smtp-port', '--sieve-port'] as $option) { + foreach (['0', '-1', '65536', '993typo', '1.5', '1e3', '99999999999999999999'] as $port) { + $cases[$option . '=' . $port] = [$option, $port]; + } + } + return $cases; + } + + /** @dataProvider validPortBoundaries */ + public function testAcceptsPortBoundaries(int $port): void { + $this->provisioningManager->expects(self::once()) + ->method('newProvisioning') + ->with(self::callback(static fn (array $data): bool => $data['imapPort'] === $port + && $data['smtpPort'] === $port && $data['sievePort'] === $port)) + ->willReturn(new Provisioning()); + + $status = $this->tester->execute(array_merge($this->options, [ + '--imap-port' => (string)$port, + '--smtp-port' => (string)$port, + '--sieve-port' => (string)$port, + '--sieve-host' => 'sieve.example.com', + ])); + + self::assertSame(Command::SUCCESS, $status); + } + + public static function validPortBoundaries(): array { + return [[1], [65535]]; + } + + public function testRequiresPortWhenEnablingSieve(): void { + $this->provisioningManager->expects(self::never()) + ->method('newProvisioning'); + + $status = $this->tester->execute($this->options + ['--sieve-host' => 'sieve.example.com']); + + self::assertSame(Command::INVALID, $status); + self::assertStringContainsString('sievePort is required', $this->tester->getDisplay()); + } + + public function testAllowsMissingPortWhenSieveIsDisabled(): void { + $this->provisioningManager->expects(self::once()) + ->method('newProvisioning') + ->with(self::callback(static fn (array $data): bool => $data['sieveEnabled'] === false && $data['sievePort'] === null)) + ->willReturn(new Provisioning()); + + $status = $this->tester->execute($this->options + [ + '--sieve-host' => 'sieve.example.com', + '--sieve-port' => '', + '--no-sieve' => true, + ]); + + self::assertSame(Command::SUCCESS, $status); + } + + public function testRejectsUnknownSslMode(): void { + $this->provisioningManager->expects(self::never()) + ->method('newProvisioning'); + + $status = $this->tester->execute(array_merge($this->options, ['--imap-ssl-mode' => 'starttls'])); + + self::assertSame(Command::INVALID, $status); + self::assertStringContainsString('imapSslMode', $this->tester->getDisplay()); + } + + public function testReportsInvalidFields(): void { + $exception = new ValidationException(); + $exception->setField('imapHost', false); + $this->provisioningManager->expects(self::once()) + ->method('newProvisioning') + ->willThrowException($exception); + + $status = $this->tester->execute($this->options); + + self::assertSame(Command::INVALID, $status); + self::assertStringContainsString('imapHost', $this->tester->getDisplay()); + } +} diff --git a/tests/Unit/Command/DeleteProvisioningTest.php b/tests/Unit/Command/DeleteProvisioningTest.php new file mode 100644 index 0000000000..7d051d4142 --- /dev/null +++ b/tests/Unit/Command/DeleteProvisioningTest.php @@ -0,0 +1,103 @@ +provisioningManager = $this->createMock(ProvisioningManager::class); + $this->command = new DeleteProvisioning($this->provisioningManager); + $this->command->setHelperSet(new HelperSet([new QuestionHelper()])); + $this->tester = new CommandTester($this->command); + } + + public function testDeleteConfirmed(): void { + $provisioning = new Provisioning(); + $provisioning->setId(3); + $this->provisioningManager->method('getConfigById') + ->with(3) + ->willReturn($provisioning); + $this->provisioningManager->expects(self::once()) + ->method('deprovision') + ->with($provisioning); + + $this->tester->setInputs(['y']); + $status = $this->tester->execute(['id' => '3']); + + self::assertSame(Command::SUCCESS, $status); + } + + public function testDeleteDeclined(): void { + $this->provisioningManager->method('getConfigById') + ->willReturn(new Provisioning()); + $this->provisioningManager->expects(self::never()) + ->method('deprovision'); + + $this->tester->setInputs(['n']); + $status = $this->tester->execute(['id' => '3']); + + self::assertSame(Command::SUCCESS, $status); + self::assertStringContainsString('Aborted', $this->tester->getDisplay()); + } + + public function testDeleteForced(): void { + $this->provisioningManager->method('getConfigById') + ->willReturn(new Provisioning()); + $this->provisioningManager->expects(self::once()) + ->method('deprovision'); + + $status = $this->tester->execute(['id' => '3', '--force' => true]); + + self::assertSame(Command::SUCCESS, $status); + } + + public function testUnknownId(): void { + $this->provisioningManager->method('getConfigById') + ->willReturn(null); + $this->provisioningManager->expects(self::never()) + ->method('deprovision'); + + $status = $this->tester->execute(['id' => '3']); + + self::assertSame(Command::FAILURE, $status); + } + + /** @dataProvider invalidIds */ + public function testRejectsInvalidIdBeforeDeleting(string $id): void { + $this->provisioningManager->expects(self::never()) + ->method('getConfigById'); + $this->provisioningManager->expects(self::never()) + ->method('deprovision'); + + $status = $this->tester->execute(['id' => $id, '--force' => true]); + + self::assertSame(Command::INVALID, $status); + self::assertStringContainsString('positive integer', $this->tester->getDisplay()); + } + + public static function invalidIds(): array { + return [[''], ['0'], ['-1'], ['3typo'], ['3.5'], ['3e1'], ['99999999999999999999']]; + } +} diff --git a/tests/Unit/Command/ListProvisioningsTest.php b/tests/Unit/Command/ListProvisioningsTest.php new file mode 100644 index 0000000000..3a85731520 --- /dev/null +++ b/tests/Unit/Command/ListProvisioningsTest.php @@ -0,0 +1,80 @@ +provisioningManager = $this->createMock(ProvisioningManager::class); + $this->tester = new CommandTester(new ListProvisionings($this->provisioningManager)); + } + + private function config(): Provisioning { + $provisioning = new Provisioning(); + $provisioning->setId(3); + $provisioning->setProvisioningDomain('example.com'); + $provisioning->setEmailTemplate('%USERID%@example.com'); + $provisioning->setImapUser('%USERID%'); + $provisioning->setImapHost('imap.example.com'); + $provisioning->setImapPort(993); + $provisioning->setImapSslMode('ssl'); + $provisioning->setSmtpUser('%USERID%'); + $provisioning->setSmtpHost('smtp.example.com'); + $provisioning->setSmtpPort(587); + $provisioning->setSmtpSslMode('tls'); + $provisioning->setSieveEnabled(false); + $provisioning->enableMasterPassword('sesame', '*masteruser'); + + return $provisioning; + } + + public function testListEmpty(): void { + $this->provisioningManager->method('getConfigs') + ->willReturn([]); + + $status = $this->tester->execute([]); + + self::assertSame(Command::SUCCESS, $status); + } + + public function testList(): void { + $this->provisioningManager->method('getConfigs') + ->willReturn([$this->config()]); + + $status = $this->tester->execute([]); + + self::assertSame(Command::SUCCESS, $status); + self::assertStringContainsString('imap.example.com', $this->tester->getDisplay()); + } + + public function testListJsonHidesMasterPassword(): void { + $this->provisioningManager->method('getConfigs') + ->willReturn([$this->config()]); + + $status = $this->tester->execute(['--json' => true]); + + self::assertSame(Command::SUCCESS, $status); + $decoded = json_decode($this->tester->getDisplay(), true, 512, JSON_THROW_ON_ERROR); + self::assertSame('example.com', $decoded[0]['provisioningDomain']); + self::assertSame(Provisioning::MASTER_PASSWORD_PLACEHOLDER, $decoded[0]['masterPassword']); + } +} diff --git a/tests/Unit/Command/ProvisionAccountsTest.php b/tests/Unit/Command/ProvisionAccountsTest.php new file mode 100644 index 0000000000..9ce1efe7cb --- /dev/null +++ b/tests/Unit/Command/ProvisionAccountsTest.php @@ -0,0 +1,40 @@ +provisioningManager = $this->createMock(ProvisioningManager::class); + $this->tester = new CommandTester(new ProvisionAccounts($this->provisioningManager)); + } + + public function testProvision(): void { + $this->provisioningManager->expects(self::once()) + ->method('provision') + ->willReturn(42); + + $status = $this->tester->execute([]); + + self::assertSame(Command::SUCCESS, $status); + self::assertStringContainsString('42', $this->tester->getDisplay()); + } +} diff --git a/tests/Unit/Command/UpdateProvisioningTest.php b/tests/Unit/Command/UpdateProvisioningTest.php new file mode 100644 index 0000000000..1aa01a832e --- /dev/null +++ b/tests/Unit/Command/UpdateProvisioningTest.php @@ -0,0 +1,257 @@ +provisioningManager = $this->createMock(ProvisioningManager::class); + $command = new UpdateProvisioning($this->provisioningManager); + $command->setHelperSet(new HelperSet([new QuestionHelper()])); + $this->tester = new CommandTester($command); + } + + private function existingConfig(): Provisioning { + $provisioning = new Provisioning(); + $provisioning->setId(3); + $provisioning->setProvisioningDomain('example.com'); + $provisioning->setEmailTemplate('%USERID%@example.com'); + $provisioning->setImapUser('%USERID%'); + $provisioning->setImapHost('imap.example.com'); + $provisioning->setImapPort(993); + $provisioning->setImapSslMode('ssl'); + $provisioning->setSmtpUser('%USERID%'); + $provisioning->setSmtpHost('smtp.example.com'); + $provisioning->setSmtpPort(587); + $provisioning->setSmtpSslMode('tls'); + $provisioning->setSieveEnabled(false); + $provisioning->enableMasterPassword('sesame', '*masteruser'); + + return $provisioning; + } + + public function testKeepsUntouchedValues(): void { + $this->provisioningManager->method('getConfigById') + ->with(3) + ->willReturn($this->existingConfig()); + $this->provisioningManager->expects(self::once()) + ->method('updateProvisioning') + ->with(self::callback(static function (array $data): bool { + return $data['id'] === 3 + && $data['imapHost'] === 'imap.example.net' + && $data['smtpHost'] === 'smtp.example.com' + && $data['emailTemplate'] === '%USERID%@example.com' + && $data['masterPasswordEnabled'] === true + && $data['masterPassword'] === Provisioning::MASTER_PASSWORD_PLACEHOLDER; + })); + + $status = $this->tester->execute([ + 'id' => '3', + '--imap-host' => 'imap.example.net', + ]); + + self::assertSame(Command::SUCCESS, $status); + } + + public function testDisablesMasterPassword(): void { + $this->provisioningManager->method('getConfigById') + ->willReturn($this->existingConfig()); + $this->provisioningManager->expects(self::once()) + ->method('updateProvisioning') + ->with(self::callback(static function (array $data): bool { + return $data['masterPasswordEnabled'] === false + && $data['masterPassword'] === '' + && $data['masterUser'] === ''; + })); + + $status = $this->tester->execute([ + 'id' => '3', + '--no-master-password' => true, + ]); + + self::assertSame(Command::SUCCESS, $status); + } + + public function testKeepsSieveDisabledWithoutSieveOptions(): void { + $config = $this->existingConfig(); + $config->setSieveHost('sieve.example.com'); + $this->provisioningManager->method('getConfigById') + ->willReturn($config); + $this->provisioningManager->expects(self::once()) + ->method('updateProvisioning') + ->with(self::callback(static fn (array $data): bool => $data['sieveEnabled'] === false)); + + $status = $this->tester->execute(['id' => '3']); + + self::assertSame(Command::SUCCESS, $status); + } + + public function testUnknownId(): void { + $this->provisioningManager->method('getConfigById') + ->willReturn(null); + $this->provisioningManager->expects(self::never()) + ->method('updateProvisioning'); + + $status = $this->tester->execute(['id' => '3']); + + self::assertSame(Command::FAILURE, $status); + } + + public function testReportsInvalidFields(): void { + $this->provisioningManager->method('getConfigById') + ->willReturn($this->existingConfig()); + $exception = new ValidationException(); + $exception->setField('emailTemplate', false); + $this->provisioningManager->method('updateProvisioning') + ->willThrowException($exception); + + $status = $this->tester->execute([ + 'id' => '3', + '--email-template' => '', + ]); + + self::assertSame(Command::INVALID, $status); + self::assertStringContainsString('emailTemplate', $this->tester->getDisplay()); + } + + /** @dataProvider invalidIds */ + public function testRejectsInvalidIdBeforeUpdating(string $id): void { + $this->provisioningManager->expects(self::never()) + ->method('getConfigById'); + $this->provisioningManager->expects(self::never()) + ->method('updateProvisioning'); + + $status = $this->tester->execute(['id' => $id, '--imap-host' => 'imap.example.net']); + + self::assertSame(Command::INVALID, $status); + self::assertStringContainsString('positive integer', $this->tester->getDisplay()); + } + + public static function invalidIds(): array { + return [[''], ['0'], ['-1'], ['3typo'], ['3.5'], ['3e1'], ['99999999999999999999']]; + } + + /** @dataProvider passwordInputModes */ + public function testUpdatesMasterPasswordFromInput(bool $interactive): void { + $this->provisioningManager->method('getConfigById') + ->willReturn($this->existingConfig()); + $this->provisioningManager->expects(self::once()) + ->method('updateProvisioning') + ->with(self::callback(static fn (array $data): bool => $data['masterPasswordEnabled'] === true + && $data['masterPassword'] === ' new password ' && $data['masterUser'] === '*masteruser')); + + $this->tester->setInputs([' new password ']); + $status = $this->tester->execute(['id' => '3', '--master-password' => null], ['interactive' => $interactive]); + + self::assertSame(Command::SUCCESS, $status); + self::assertStringNotContainsString('new password', $this->tester->getDisplay()); + } + + public static function passwordInputModes(): array { + return [[true], [false]]; + } + + public function testRejectsMissingPasswordInput(): void { + $this->provisioningManager->method('getConfigById') + ->willReturn($this->existingConfig()); + $this->provisioningManager->expects(self::never()) + ->method('updateProvisioning'); + + $status = $this->tester->execute(['id' => '3', '--master-password' => null], ['interactive' => false]); + + self::assertSame(Command::INVALID, $status); + } + + /** @dataProvider invalidPorts */ + public function testRejectsInvalidPorts(string $option, string $port): void { + $this->provisioningManager->method('getConfigById') + ->willReturn($this->existingConfig()); + $this->provisioningManager->expects(self::never()) + ->method('updateProvisioning'); + + $status = $this->tester->execute(['id' => '3', $option => $port]); + + self::assertSame(Command::INVALID, $status); + } + + public static function invalidPorts(): array { + return [['--imap-port', ''], ['--smtp-port', '587typo'], ['--sieve-port', '65536']]; + } + + public function testRequiresPortWhenEnablingSieve(): void { + $this->provisioningManager->method('getConfigById') + ->willReturn($this->existingConfig()); + $this->provisioningManager->expects(self::never()) + ->method('updateProvisioning'); + + $status = $this->tester->execute(['id' => '3', '--sieve-host' => 'sieve.example.com']); + + self::assertSame(Command::INVALID, $status); + self::assertStringContainsString('sievePort is required', $this->tester->getDisplay()); + } + + public function testKeepsExistingPortWhenEnablingSieve(): void { + $config = $this->existingConfig(); + $config->setSievePort(4190); + $this->provisioningManager->method('getConfigById') + ->willReturn($config); + $this->provisioningManager->expects(self::once()) + ->method('updateProvisioning') + ->with(self::callback(static fn (array $data): bool => $data['sieveEnabled'] === true && $data['sievePort'] === 4190)); + + $status = $this->tester->execute(['id' => '3', '--sieve-host' => 'sieve.example.com']); + + self::assertSame(Command::SUCCESS, $status); + } + + public function testCannotClearPortWhileSieveIsEnabled(): void { + $config = $this->existingConfig(); + $config->setSieveEnabled(true); + $config->setSievePort(4190); + $this->provisioningManager->method('getConfigById') + ->willReturn($config); + $this->provisioningManager->expects(self::never()) + ->method('updateProvisioning'); + + $status = $this->tester->execute(['id' => '3', '--sieve-port' => '']); + + self::assertSame(Command::INVALID, $status); + } + + public function testCanDisableSieveAndClearItsPort(): void { + $config = $this->existingConfig(); + $config->setSieveEnabled(true); + $config->setSievePort(4190); + $this->provisioningManager->method('getConfigById') + ->willReturn($config); + $this->provisioningManager->expects(self::once()) + ->method('updateProvisioning') + ->with(self::callback(static fn (array $data): bool => $data['sieveEnabled'] === false && $data['sievePort'] === null)); + + $status = $this->tester->execute(['id' => '3', '--no-sieve' => true, '--sieve-port' => '']); + + self::assertSame(Command::SUCCESS, $status); + } +}