diff --git a/src/.env.example b/src/.env.example --- a/src/.env.example +++ b/src/.env.example @@ -90,6 +90,12 @@ #OPENVIDU_WEBHOOK_EVENTS=[sessionCreated,sessionDestroyed,participantJoined,participantLeft,webrtcConnectionCreated,webrtcConnectionDestroyed,recordingStatusChanged,filterEventDispatched,mediaNodeStatusChanged] #OPENVIDU_WEBHOOK_HEADERS=[\"Authorization:\ Basic\ SOMETHING\"] +PGP_ENABLED= +PGP_BINARY= +PGP_AGENT= +PGP_GPGCONF= +PGP_LENGTH= + REDIS_HOST=127.0.0.1 REDIS_PASSWORD=null REDIS_PORT=6379 diff --git a/src/app/Auth/SecondFactor.php b/src/app/Auth/SecondFactor.php --- a/src/app/Auth/SecondFactor.php +++ b/src/app/Auth/SecondFactor.php @@ -3,7 +3,6 @@ namespace App\Auth; use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\DB; use Kolab2FA\Storage\Base; /** @@ -307,16 +306,6 @@ */ public static function dbh() { - $dsn = \config('2fa.dsn'); - - if (empty($dsn)) { - \Log::warning("2-FACTOR database not configured"); - - return DB::connection(\config('database.default')); - } - - \Config::set('database.connections.2fa', ['url' => $dsn]); - - return DB::connection('2fa'); + return \App\Backends\Roundcube::dbh(); } } diff --git a/src/app/Backends/PGP.php b/src/app/Backends/PGP.php new file mode 100644 --- /dev/null +++ b/src/app/Backends/PGP.php @@ -0,0 +1,208 @@ +deleteDirectory($homedir); + } else { + Storage::disk('pgp')->delete(Storage::disk('pgp')->files($homedir)); + + foreach (Storage::disk('pgp')->files($homedir) as $subdir) { + Storage::disk('pgp')->deleteDirectory($subdir); + } + } + + // Remove all files from the Enigma database + // Note: This will cause existing files in the Roundcube filesystem + // to be removed, but only if the user used the Enigma functionality + Roundcube::enigmaCleanup($user->email); + } + + /** + * Generate a keypair. + * This will also initialize the user GPG homedir content. + * + * @param \App\User $user User object + * @param string $email Email address to use for the key + * + * @throws \Exception + */ + public static function keypairCreate(User $user, string $email): void + { + self::initGPG($user, true); + + if ($user->email === $email) { + // Make sure the homedir is empty for a new user + self::homedirCleanup($user); + } + + $keygen = new \Crypt_GPG_KeyGenerator(self::$config); + + $key = $keygen + // ->setPassphrase() + // ->setExpirationDate(0) + ->setKeyParams(\Crypt_GPG_SubKey::ALGORITHM_RSA, \config('pgp.length')) + ->setSubKeyParams(\Crypt_GPG_SubKey::ALGORITHM_RSA, \config('pgp.length')) + ->generateKey(null, $email); + + // Store the keypair in Roundcube Enigma storage + self::dbSave(true); + + // Get the ASCII armored data of the public key + $armor = self::$gpg->exportPublicKey((string) $key, true); + + // Register the public key in DNS + self::keyRegister($email, $armor); + + // FIXME: Should we remove the files from the worker filesystem? + // They are still in database and Roundcube hosts' filesystem + } + + /** + * List (public and private) keys from a user keyring. + * + * @param \App\User $user User object + * + * @returns \Crypt_GPG_Key[] List of keys + * @throws \Exception + */ + public static function listKeys(User $user): array + { + self::initGPG($user); + + return self::$gpg->getKeys(''); + } + + /** + * Debug logging callback + */ + public static function logDebug($msg): void + { + \Log::debug("[GPG] $msg"); + } + + /** + * Register the key in the WOAT DNS system + * + * @param string $email Email address + * @param string $key The ASCII-armored key content + */ + public static function keyRegister(string $email, string $key) + { + // TODO + } + + /** + * Remove the key from the WOAT DNS system + * + * @param string $email Email address + */ + public static function keyUnregister(string $email) + { + // TODO + } + + /** + * Prepare Crypt_GPG configuration + */ + private static function initConfig(User $user, $nosync = false): void + { + if (!empty(self::$config) && self::$config['email'] == $user->email) { + return; + } + + $debug = \config('app.debug'); + $binary = \config('pgp.binary'); + $agent = \config('pgp.agent'); + $gpgconf = \config('pgp.gpgconf'); + + $dir = self::setHomedir($user); + $options = [ + 'email' => $user->email, // this one is not a Crypt_GPG option + 'dir' => $dir, // this one is not a Crypt_GPG option + 'homedir' => \config('filesystems.disks.pgp.root') . '/' . $dir, + 'debug' => $debug ? 'App\Backends\PGP::logDebug' : null, + ]; + + if ($binary) { + $options['binary'] = $binary; + } + + if ($agent) { + $options['agent'] = $agent; + } + + if ($gpgconf) { + $options['gpgconf'] = $gpgconf; + } + + self::$config = $options; + + // Sync the homedir directory content with the Enigma storage + if (!$nosync) { + self::dbSync(); + } + } + + /** + * Initialize Crypt_GPG + */ + private static function initGPG(User $user, $nosync = false): void + { + self::initConfig($user, $nosync); + + self::$gpg = new \Crypt_GPG(self::$config); + } + + /** + * Prepare a homedir for the user + */ + private static function setHomedir(User $user): string + { + // Create a subfolder using two first digits of the user ID + $dir = sprintf('%02d', substr((string) $user->id, 0, 2)) . '/' . $user->email; + + Storage::disk('pgp')->makeDirectory($dir); + + return $dir; + } + + /** + * Synchronize keys database of a user + */ + private static function dbSync(): void + { + Roundcube::enigmaSync(self::$config['email'], self::$config['dir']); + } + + /** + * Save the keys database + */ + private static function dbSave($is_empty = false): void + { + Roundcube::enigmaSave(self::$config['email'], self::$config['dir'], $is_empty); + } +} diff --git a/src/app/Backends/Roundcube.php b/src/app/Backends/Roundcube.php new file mode 100644 --- /dev/null +++ b/src/app/Backends/Roundcube.php @@ -0,0 +1,256 @@ +table(self::FILESTORE_TABLE) + ->where('user_id', self::userId($email)) + ->where('context', 'enigma') + ->delete(); + } + + /** + * List all files from the Enigma filestore. + * + * @param string $email User email address + * + * @return array List of Enigma filestore records + */ + public static function enigmaList(string $email): array + { + return self::dbh()->table(self::FILESTORE_TABLE) + ->where('user_id', self::userId($email)) + ->where('context', 'enigma') + ->orderBy('filename') + ->get() + ->all(); + } + + /** + * Synchronize Enigma filestore from/to specified directory + * + * @param string $email User email address + * @param string $homedir Directory location + */ + public static function enigmaSync(string $email, string $homedir): void + { + $db = self::dbh(); + $debug = \config('app.debug'); + $user_id = self::userId($email); + $root = \config('filesystems.disks.pgp.root'); + $fs = Storage::disk('pgp'); + $files = []; + + $result = $db->table(self::FILESTORE_TABLE)->select('file_id', 'filename', 'mtime') + ->where('user_id', $user_id) + ->where('context', 'enigma') + ->get(); + + foreach ($result as $record) { + $file = $homedir . '/' . $record->filename; + $mtime = $fs->exists($file) ? $fs->lastModified($file) : 0; + $files[] = $record->filename; + + if ($mtime < $record->mtime) { + $record = $db->table(self::FILESTORE_TABLE)->select('file_id', 'data', 'mtime') + ->where('file_id', $record->file_id) + ->first(); + + $data = $record ? base64_decode($record->data) : false; + + if ($data === false) { + \Log::error("Failed to sync $file ({$record->file_id}). Decode error."); + continue; + } + + if ($fs->put($file, $data, true)) { + // Note: Laravel Filesystem API does not provide touch method + touch("$root/$file", $record->mtime); + + if ($debug) { + \Log::debug("[SYNC] Fetched file: $file"); + } + } + } + } + + // Remove files not in database + foreach (array_diff(self::enigmaFilesList($homedir), $files) as $file) { + $file = $homedir . '/' . $file; + + if ($fs->delete($file)) { + if ($debug) { + \Log::debug("[SYNC] Removed file: $file"); + } + } + } + + // No records found, do initial sync if already have the keyring + if (empty($file)) { + self::enigmaSave(true, $homedir); + } + } + + /** + * Save the keys database + * + * @param string $email User email address + * @param string $homedir Directory location + * @param bool $is_empty Set to Tre if it is a initial save + */ + public static function enigmaSave(string $email, string $homedir, bool $is_empty = false): void + { + $db = self::dbh(); + $debug = \config('app.debug'); + $user_id = self::userId($email); + $fs = Storage::disk('pgp'); + $records = []; + + if (!$is_empty) { + $records = $db->table(self::FILESTORE_TABLE)->select('file_id', 'filename', 'mtime') + ->where('user_id', $user_id) + ->where('context', 'enigma') + ->get() + ->keyBy('filename') + ->all(); + } + + foreach (self::enigmaFilesList($homedir) as $filename) { + $file = $homedir . '/' . $filename; + $mtime = $fs->exists($file) ? $fs->lastModified($file) : 0; + + $existing = !empty($records[$filename]) ? $records[$filename] : null; + unset($records[$filename]); + + if ($mtime && (empty($existing) || $mtime > $existing->mtime)) { + $data = base64_encode($fs->get($file)); +/* + if (empty($maxsize)) { + $maxsize = min($db->get_variable('max_allowed_packet', 1048500), 4*1024*1024) - 2000; + } + + if (strlen($data) > $maxsize) { + \Log::error("Failed to save $file. Size exceeds max_allowed_packet."); + continue; + } +*/ + $result = $db->table(self::FILESTORE_TABLE)->updateOrInsert( + ['user_id' => $user_id, 'context' => 'enigma', 'filename' => $filename], + ['mtime' => $mtime, 'data' => $data] + ); + + if ($debug) { + \Log::debug("[SYNC] Pushed file: $file"); + } + } + } + + // Delete removed files from database + foreach (array_keys($records) as $filename) { + $file = $homedir . '/' . $filename; + $result = $db->table(self::FILESTORE_TABLE) + ->where('user_id', $user_id) + ->where('context', 'enigma') + ->where('filename', $filename) + ->delete(); + + if ($debug) { + \Log::debug("[SYNC] Removed file: $file"); + } + } + } + + /** + * Find the Roundcube user identifier for the specified user. + * + * @param string $email User email address + * @param bool $create Make sure the user record exists + * + * @returns ?int Roundcube user identifier + */ + public static function userId(string $email, bool $create = true): ?int + { + $db = self::dbh(); + + $user = $db->table(self::USERS_TABLE)->select('user_id') + ->where('username', \strtolower($email)) + ->first(); + + // Create a user record, without it we can't use the Roundcube storage + if (empty($user)) { + if (!$create) { + return null; + } + + $uri = \parse_url(\config('imap.uri')); + + return (int) $db->table(self::USERS_TABLE)->insertGetId( + [ + 'username' => $email, + 'mail_host' => $uri['host'], + 'created' => now()->toDateTimeString(), + ], + 'user_id' + ); + } + + return (int) $user->user_id; + } + + /** + * Returns list of Enigma user homedir files to backup/sync + */ + private static function enigmaFilesList(string $homedir) + { + $files = []; + $fs = Storage::disk('pgp'); + + foreach (self::$enigma_files as $file) { + if ($fs->exists($homedir . '/' . $file)) { + $files[] = $file; + } + } + + foreach ($fs->files($homedir . '/private-keys-v1.d') as $file) { + if (preg_match('/\.key$/', $file)) { + $files[] = substr($file, strlen($homedir . '/')); + } + } + + return $files; + } +} diff --git a/src/app/Jobs/PGP/KeyCreateJob.php b/src/app/Jobs/PGP/KeyCreateJob.php new file mode 100644 --- /dev/null +++ b/src/app/Jobs/PGP/KeyCreateJob.php @@ -0,0 +1,69 @@ +isDeleted()`), or + * * the user is actually deleted (`$user->deleted_at`) + * * the alias is actually deleted + * * there was an error in keypair generation process + */ +class KeyCreateJob extends UserJob +{ + /** + * Create a new job instance. + * + * @param int $userId User identifier. + * @param string $userEmail User email address for the key + * + * @return void + */ + public function __construct(int $userId, string $userEmail) + { + $this->userId = $userId; + $this->userEmail = $userEmail; + } + + /** + * Execute the job. + * + * @return void + * + * @throws \Exception + */ + public function handle() + { + $user = $this->getUser(); + + if (!$user) { + return; + } + + // sanity checks + if ($user->isDeleted()) { + $this->fail(new \Exception("User {$this->userId} is marked as deleted.")); + return; + } + + if ($user->trashed()) { + $this->fail(new \Exception("User {$this->userId} is actually deleted.")); + return; + } + + if ( + $this->userEmail != $user->email + && !$user->aliases()->where('alias', $this->userEmail)->exists() + ) { + $this->fail(new \Exception("Alias {$this->userEmail} is actually deleted.")); + return; + } + + \App\Backends\PGP::keypairCreate($user, $this->userEmail); + } +} diff --git a/src/app/Jobs/PGP/KeyUnregisterJob.php b/src/app/Jobs/PGP/KeyUnregisterJob.php new file mode 100644 --- /dev/null +++ b/src/app/Jobs/PGP/KeyUnregisterJob.php @@ -0,0 +1,42 @@ +email = $email; + } + + /** + * Execute the job. + * + * @return void + * + * @throws \Exception + */ + public function handle() + { + \App\Backends\PGP::keyUnregister($this->email); + } +} diff --git a/src/app/Observers/UserAliasObserver.php b/src/app/Observers/UserAliasObserver.php --- a/src/app/Observers/UserAliasObserver.php +++ b/src/app/Observers/UserAliasObserver.php @@ -3,6 +3,7 @@ namespace App\Observers; use App\Domain; +use App\Tenant; use App\User; use App\UserAlias; @@ -51,6 +52,10 @@ { if ($alias->user) { \App\Jobs\User\UpdateJob::dispatch($alias->user_id); + + if (Tenant::getConfig($alias->user->tenant_id, 'pgp.enable')) { + \App\Jobs\PGP\KeyCreateJob::dispatch($alias->user_id, $alias->alias); + } } } @@ -79,6 +84,10 @@ { if ($alias->user) { \App\Jobs\User\UpdateJob::dispatch($alias->user_id); + + if (Tenant::getConfig($alias->user->tenant_id, 'pgp.enable')) { + \App\Jobs\PGP\KeyUnregisterJob::dispatch($alias->alias); + } } } } diff --git a/src/app/Observers/UserObserver.php b/src/app/Observers/UserObserver.php --- a/src/app/Observers/UserObserver.php +++ b/src/app/Observers/UserObserver.php @@ -87,6 +87,10 @@ ]; \App\Jobs\User\CreateJob::withChain($chain)->dispatch($user->id); + + if (\App\Tenant::getConfig($user->tenant_id, 'pgp.enable')) { + \App\Jobs\PGP\KeyCreateJob::dispatch($user->id, $user->email); + } } /** diff --git a/src/app/Tenant.php b/src/app/Tenant.php --- a/src/app/Tenant.php +++ b/src/app/Tenant.php @@ -50,6 +50,7 @@ // - mail.from.address and mail.from.name // - mail.reply_to.address and mail.reply_to.name // - app.kb.account_delete and app.kb.account_suspended + // - pgp.enable if ($key == 'app.name') { return $tenant ? $tenant->title : \config($key); diff --git a/src/composer.json b/src/composer.json --- a/src/composer.json +++ b/src/composer.json @@ -27,6 +27,7 @@ "mlocati/spf-lib": "^3.0", "mollie/laravel-mollie": "^2.9", "morrislaptop/laravel-queue-clear": "^1.2", + "pear/crypt_gpg": "dev-master", "silviolleite/laravelpwa": "^2.0", "spatie/laravel-translatable": "^4.2", "spomky-labs/otphp": "~4.0.0", diff --git a/src/config/2fa.php b/src/config/2fa.php --- a/src/config/2fa.php +++ b/src/config/2fa.php @@ -9,6 +9,4 @@ 'issuer' => env('APP_NAME', 'Laravel'), ], - 'dsn' => env('MFA_DSN'), - ]; diff --git a/src/config/database.php b/src/config/database.php --- a/src/config/database.php +++ b/src/config/database.php @@ -96,6 +96,10 @@ 'prefix' => '', 'prefix_indexes' => true, ], + + 'roundcube' => [ + 'url' => env('DB_ROUNDCUBE_URL', env('MFA_DSN')), + ], ], /* diff --git a/src/config/filesystems.php b/src/config/filesystems.php --- a/src/config/filesystems.php +++ b/src/config/filesystems.php @@ -48,6 +48,11 @@ 'root' => storage_path('app'), ], + 'pgp' => [ + 'driver' => 'local', + 'root' => storage_path('app/keys'), + ], + 'public' => [ 'driver' => 'local', 'root' => storage_path('app/public'), diff --git a/src/config/pgp.php b/src/config/pgp.php new file mode 100644 --- /dev/null +++ b/src/config/pgp.php @@ -0,0 +1,20 @@ + env('PGP_ENABLE', false), + + // gpg binary location + 'binary' => env('PGP_BINARY'), + + // gpg-agent location + 'agent' => env('PGP_AGENT'), + + // gpgconf location + 'gpgconf' => env('PGP_GPGCONF'), + + // Default size of the new RSA key + 'length' => (int) env('PGP_LENGTH', 3072), + +]; diff --git a/src/phpunit.xml b/src/phpunit.xml --- a/src/phpunit.xml +++ b/src/phpunit.xml @@ -41,5 +41,6 @@ + diff --git a/src/tests/Feature/Jobs/PGP/KeyCreateTest.php b/src/tests/Feature/Jobs/PGP/KeyCreateTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Jobs/PGP/KeyCreateTest.php @@ -0,0 +1,123 @@ +getTestUser('john@kolab.org'); + UserAlias::where('alias', 'test-alias@kolab.org')->delete(); + PGP::homedirCleanup($user); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $user = $this->getTestUser('john@kolab.org'); + UserAlias::where('alias', 'test-alias@kolab.org')->delete(); + PGP::homedirCleanup($user); + + parent::tearDown(); + } + + /** + * Test job handle + * + * @group pgp + */ + public function testHandle(): void + { + $user = $this->getTestUser('john@kolab.org'); + + $job = new \App\Jobs\PGP\KeyCreateJob($user->id, $user->email); + $job->handle(); + + // Assert the Enigma storage has been initialized and contains the key + $files = Roundcube::enigmaList($user->email); + // TODO: More detailed asserts on the filestore content, but it's specific to GPG version + $this->assertTrue(count($files) > 1); + + // Assert the created keypair parameters + $keys = PGP::listKeys($user); + + $this->assertCount(1, $keys); + + $userIds = $keys[0]->getUserIds(); + $this->assertCount(1, $userIds); + $this->assertSame($user->email, $userIds[0]->getEmail()); + $this->assertSame('', $userIds[0]->getName()); + $this->assertSame('', $userIds[0]->getComment()); + $this->assertSame(true, $userIds[0]->isValid()); + $this->assertSame(false, $userIds[0]->isRevoked()); + + $key = $keys[0]->getPrimaryKey(); + $this->assertSame(\Crypt_GPG_SubKey::ALGORITHM_RSA, $key->getAlgorithm()); + $this->assertSame(0, $key->getExpirationDate()); + $this->assertSame((int) \config('pgp.length'), $key->getLength()); + $this->assertSame(true, $key->hasPrivate()); + $this->assertSame(true, $key->canSign()); + $this->assertSame(false, $key->canEncrypt()); + $this->assertSame(false, $key->isRevoked()); + + $key = $keys[0]->getSubKeys()[1]; + $this->assertSame(\Crypt_GPG_SubKey::ALGORITHM_RSA, $key->getAlgorithm()); + $this->assertSame(0, $key->getExpirationDate()); + $this->assertSame((int) \config('pgp.length'), $key->getLength()); + $this->assertSame(false, $key->canSign()); + $this->assertSame(true, $key->canEncrypt()); + $this->assertSame(false, $key->isRevoked()); + + // TODO: Assert the public key in DNS? + + // Test an alias + Queue::fake(); + UserAlias::create(['user_id' => $user->id, 'alias' => 'test-alias@kolab.org']); + $job = new \App\Jobs\PGP\KeyCreateJob($user->id, 'test-alias@kolab.org'); + $job->handle(); + + // Assert the created keypair parameters + $keys = PGP::listKeys($user); + + $this->assertCount(2, $keys); + + $userIds = $keys[1]->getUserIds(); + $this->assertCount(1, $userIds); + $this->assertSame('test-alias@kolab.org', $userIds[0]->getEmail()); + $this->assertSame('', $userIds[0]->getName()); + $this->assertSame('', $userIds[0]->getComment()); + $this->assertSame(true, $userIds[0]->isValid()); + $this->assertSame(false, $userIds[0]->isRevoked()); + + $key = $keys[1]->getPrimaryKey(); + $this->assertSame(\Crypt_GPG_SubKey::ALGORITHM_RSA, $key->getAlgorithm()); + $this->assertSame(0, $key->getExpirationDate()); + $this->assertSame((int) \config('pgp.length'), $key->getLength()); + $this->assertSame(true, $key->hasPrivate()); + $this->assertSame(true, $key->canSign()); + $this->assertSame(false, $key->canEncrypt()); + $this->assertSame(false, $key->isRevoked()); + + $key = $keys[1]->getSubKeys()[1]; + $this->assertSame(\Crypt_GPG_SubKey::ALGORITHM_RSA, $key->getAlgorithm()); + $this->assertSame(0, $key->getExpirationDate()); + $this->assertSame((int) \config('pgp.length'), $key->getLength()); + $this->assertSame(false, $key->canSign()); + $this->assertSame(true, $key->canEncrypt()); + $this->assertSame(false, $key->isRevoked()); + } +} diff --git a/src/tests/Feature/UserTest.php b/src/tests/Feature/UserTest.php --- a/src/tests/Feature/UserTest.php +++ b/src/tests/Feature/UserTest.php @@ -25,6 +25,7 @@ public function tearDown(): void { + \App\TenantSetting::truncate(); $this->deleteTestUser('user-test@' . \config('app.domain')); $this->deleteTestUser('UserAccountA@UserAccount.com'); $this->deleteTestUser('UserAccountB@UserAccount.com'); @@ -247,15 +248,14 @@ */ public function testCreateJobs(): void { - // Fake the queue, assert that no jobs were pushed... Queue::fake(); - Queue::assertNothingPushed(); $user = User::create([ 'email' => 'user-test@' . \config('app.domain') ]); Queue::assertPushed(\App\Jobs\User\CreateJob::class, 1); + Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 0); Queue::assertPushed( \App\Jobs\User\CreateJob::class, @@ -292,6 +292,33 @@ } /** + * Verify user creation process invokes the PGP keys creation job (if configured) + */ + public function testCreatePGPJob(): void + { + Queue::fake(); + + \App\Tenant::find(\config('app.tenant_id'))->setSetting('pgp.enable', 1); + + $user = User::create([ + 'email' => 'user-test@' . \config('app.domain') + ]); + + Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 1); + + Queue::assertPushed( + \App\Jobs\PGP\KeyCreateJob::class, + function ($job) use ($user) { + $userEmail = TestCase::getObjectProperty($job, 'userEmail'); + $userId = TestCase::getObjectProperty($job, 'userId'); + + return $userEmail === $user->email + && $userId === $user->id; + } + ); + } + + /** * Tests for User::domains() */ public function testDomains(): void @@ -576,6 +603,8 @@ $this->assertInstanceOf(User::class, $result); $this->assertSame($user->id, $result->id); + Queue::fake(); + // A case where two users have the same alias $ned = $this->getTestUser('ned@kolab.org'); $ned->setAliases(['joe.monster@kolab.org']); @@ -739,10 +768,15 @@ $this->assertCount(0, $user->aliases->all()); + $user->tenant->setSetting('pgp.enable', 1); + // Add an alias $user->setAliases(['UserAlias1@UserAccount.com']); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); + Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 1); + + $user->tenant->setSetting('pgp.enable', 0); $aliases = $user->aliases()->get(); $this->assertCount(1, $aliases); @@ -752,16 +786,22 @@ $user->setAliases(['UserAlias1@UserAccount.com', 'UserAlias2@UserAccount.com']); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 2); + Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 1); $aliases = $user->aliases()->orderBy('alias')->get(); $this->assertCount(2, $aliases); $this->assertSame('useralias1@useraccount.com', $aliases[0]->alias); $this->assertSame('useralias2@useraccount.com', $aliases[1]->alias); + $user->tenant->setSetting('pgp.enable', 1); + // Remove an alias $user->setAliases(['UserAlias1@UserAccount.com']); + $user->tenant->setSetting('pgp.enable', 0); + Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 3); + Queue::assertPushed(\App\Jobs\PGP\KeyUnregisterJob::class, 1); $aliases = $user->aliases()->get(); $this->assertCount(1, $aliases);