diff --git a/src/app/Backends/Amavis/Policy.php b/src/app/Backends/Amavis/Policy.php new file mode 100644 --- /dev/null +++ b/src/app/Backends/Amavis/Policy.php @@ -0,0 +1,118 @@ + The attributes that should be cast */ + protected $casts = [ + 'spam_tag_level' => 'float', + 'spam_tag2_level' => 'float', + 'spam_tag3_level' => 'float', + 'spam_kill_level' => 'float', + ]; + + /** @var array The attributes that can be null */ + protected $nullable = [ + 'spam_tag_level', + 'spam_tag2_level', + 'spam_tag3_level', + 'spam_kill_level', + ]; + + /** @var string Database table name */ + protected $table = 'amavis_policy'; + + /** + * Return default settings. + */ + public static function defaults(): array + { + return [ + // TODO: Get default values from config? + ]; + } + + /** + * Returns all supported settings with their type + * + * @return array + */ + public static function policyDefinition() + { + return [ + 'spam_tag_level' => 'float', + 'spam_tag2_level' => 'float', + 'spam_tag3_level' => 'float', + 'spam_kill_level' => 'float', + ]; + } + + /** + * Saves/Updates Amavis policy for a user. + * + * @param string $email Email address + * @param array $prefs Policy settings + */ + public static function saveFor(string $email, array $prefs) + { + if (empty($prefs)) { + return; + } + + $user = User::where('email', $email)->first(); + + $policy = $user ? $user->policy : new self(); + + foreach ($prefs as $key => $value) { + $policy->{$key} = $value; + } + + $policy->save(); + + if (empty($user)) { + User::create([ + 'email' => $email, + 'policy_id' => $policy->id, + ]); + } + } + + /** + * Validate policy setting value. + * + * @param string $name Setting name + * @param mixed $value Setting value + */ + public static function validate($name, $value): bool + { + $definition = self::policyDefinition(); + + if (array_key_exists($name, $definition)) { + if ($value === null) { + return true; + } + + switch ($definition[$name]) { + case 'float': + return preg_match('/^[0-9]+(\.[0-9]+)?$/', $value) === 1; + } + } + + return false; + } +} diff --git a/src/app/Backends/Amavis/User.php b/src/app/Backends/Amavis/User.php new file mode 100644 --- /dev/null +++ b/src/app/Backends/Amavis/User.php @@ -0,0 +1,36 @@ + The attributes that are mass assignable */ + protected $fillable = [ + 'email', + 'policy_id' + ]; + + /** + * The policy assigned to the user. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function policy() + { + return $this->belongsTo(Policy::class); + } +} diff --git a/src/app/Backends/Spamassassin/Userpref.php b/src/app/Backends/Spamassassin/Userpref.php new file mode 100644 --- /dev/null +++ b/src/app/Backends/Spamassassin/Userpref.php @@ -0,0 +1,171 @@ + [], + 'blacklist_from' => [], + // 'ok_locales' => [], + ]; + } + + /** + * Interact with the preference value. Value type casting. + * + * @return \Illuminate\Database\Eloquent\Casts\Attribute + */ + protected function value(): Attribute + { + return Attribute::make( + get: fn($value) => $this->formatValueOut($value), + set: fn($value) => $this->formatValueIn($value), + ); + } + + /** + * Convert input preference value into internal representation. + */ + protected function formatValueIn($value) + { + if ($value === null) { + return null; + } + + switch ($this->preference) { + case 'whitelist_from': + case 'blacklist_from': + case 'ok_locales': + return implode(' ', $value); + } + + return $value; + } + + /** + * Convert preference value into an external representation/type. + */ + protected function formatValueOut($value) + { + switch ($this->preference) { + case 'whitelist_from': + case 'blacklist_from': + case 'ok_locales': + return preg_split('/\s+/', $value, -1, PREG_SPLIT_NO_EMPTY); + } + + return $value; + } + + /** + * Saves/Updates SpamAssassin preferences for specified user. + * + * @param string $username Username + * @param array $prefs Preferences (key -> value) + */ + public static function saveFor(string $username, array $prefs) + { + if (empty($prefs)) { + return; + } + + // Update/delete existing prefs + self::where('username', $username)->get() + ->each(function ($userpref) use (&$prefs) { + if (array_key_exists($userpref->preference, $prefs)) { + $value = $prefs[$userpref->preference]; + unset($prefs[$userpref->preference]); + + if ($value === null || (is_array($value) && empty($value))) { + $userpref->delete(); + } else { + $userpref->value = $value; + $userpref->save(); + } + } + }); + + // Create new prefs + foreach ($prefs as $key => $value) { + if (is_array($value) && empty($value)) { + // Ignore empty ones + continue; + } + + $pref = new self(); + $pref->username = $username; + $pref->preference = $key; + $pref->value = $value; + $pref->save(); + } + } + + /** + * Validate preference value. + * + * @param string $name Preference name + * @param mixed $value Preference value + * @param ?int $errorIndex If an array has an invalid element, it's the index of the first invalid element + * + * @return bool True if the value is valid, False otherwise + */ + public static function validate($name, $value, &$errorIndex = null): bool + { + if ($value === null) { + return true; + } + + switch ($name) { + case 'whitelist_from': + case 'blacklist_from': + if (!is_array($value)) { + return false; + } + + $rule = [new EmailPattern()]; + + foreach ($value as $idx => $item) { + $v = Validator::make(['email' => $item], ['email' => $rule]); + if ($v->fails()) { + $errorIndex = $idx; + return false; + } + } + + return true; + + case 'ok_locales': + // TODO + return false; + } + + return false; + } +} diff --git a/src/app/Rules/EmailPattern.php b/src/app/Rules/EmailPattern.php new file mode 100644 --- /dev/null +++ b/src/app/Rules/EmailPattern.php @@ -0,0 +1,52 @@ + $input], ['email' => 'required|email']); + + if ($v->fails()) { + $this->message = \trans('validation.emailinvalid'); + return false; + } + + return true; + } + + /** + * Get the validation error message. + * + * @return string + */ + public function message(): ?string + { + return $this->message; + } +} diff --git a/src/app/Traits/UserConfigTrait.php b/src/app/Traits/UserConfigTrait.php --- a/src/app/Traits/UserConfigTrait.php +++ b/src/app/Traits/UserConfigTrait.php @@ -2,10 +2,33 @@ namespace App\Traits; +use App\Backends\Amavis\Policy as AmavisPolicy; +use App\Backends\Amavis\User as AmavisUser; +use App\Backends\Spamassassin\Userpref as SpamPref; use App\Policy\Greylist; trait UserConfigTrait { + /** + * Boot function from Laravel. + */ + protected static function bootUserConfigTrait() + { + // Remove (external) settings on user delete + static::deleted(function ($user) { + // FIXME: Should we remove the settings on force-deleting only? + // if (!$user->isForceDeleting()) { + // return; + // } + + SpamPref::where('username', $user->email)->delete(); + AmavisUser::where('email', $user->email)->each(function ($amavis) { + $amavis->policy()->delete(); + $amavis->delete(); + }); + }); + } + /** * A helper to get the user configuration. */ @@ -27,6 +50,28 @@ 'password_policy' => $settings['password_policy'], ]; + // Merge the Spamassassin/Amavis settings (defaults) + $config = array_merge( + $config, + collect(SpamPref::defaults())->mapWithKeys(fn($val, $key) => ["sa_{$key}" => $val])->all(), + collect(AmavisPolicy::defaults())->mapWithKeys(fn($val, $key) => ["amavis_{$key}" => $val])->all() + ); + + // Spamassassin settings + SpamPref::where('username', $this->email)->get() + ->each(function ($pref) use (&$config) { + $config["sa_{$pref->preference}"] = $pref->value; + }); + + // Amavis settings + if (($amavis_user = AmavisUser::where('email', $this->email)->first()) + && ($policy = $amavis_user->policy()->first()) + ) { + foreach (array_keys($policy->policyDefinition()) as $opt) { + $config["amavis_{$opt}"] = $policy->{$opt}; + } + } + return $config; } @@ -40,9 +85,27 @@ public function setConfig(array $config): array { $errors = []; + $amavis = []; + $sa = []; foreach ($config as $key => $value) { - if ($key == 'greylist_enabled') { + if (strpos($key, 'sa_') === 0) { + $sa_key = substr($key, 3); + if (SpamPref::validate($sa_key, $value, $err_idx)) { + $sa[$sa_key] = $value; + } elseif ($err_idx !== null) { + $errors[$key] = [$err_idx => \trans('validation.option-invalid-value')]; + } else { + $errors[$key] = \trans('validation.option-invalid-value'); + } + } elseif (strpos($key, 'amavis_') === 0) { + $amavis_key = substr($key, 7); + if (AmavisPolicy::validate($amavis_key, $value)) { + $amavis[$amavis_key] = $value; + } else { + $errors[$key] = \trans('validation.option-invalid-value'); + } + } elseif ($key == 'greylist_enabled') { $this->setSetting($key, $value ? 'true' : 'false'); } elseif ($key == 'guam_enabled') { $this->setSetting($key, $value ? 'true' : null); @@ -93,6 +156,9 @@ } } + SpamPref::saveFor($this->email, $sa); + AmavisPolicy::saveFor($this->email, $amavis); + return $errors; } diff --git a/src/database/migrations/2023_02_12_100000_amavis_tables.php b/src/database/migrations/2023_02_12_100000_amavis_tables.php new file mode 100644 --- /dev/null +++ b/src/database/migrations/2023_02_12_100000_amavis_tables.php @@ -0,0 +1,48 @@ +bigIncrements('id'); + //$table->char('virus_lover', 1); + //$table->char('spam_lover', 1); + $table->float('spam_tag_level')->nullable(); + $table->float('spam_tag2_level')->nullable(); + $table->float('spam_tag3_level')->nullable(); + $table->float('spam_kill_level')->nullable(); + $table->timestamps(); + }); + + Schema::create('amavis_users', function (Blueprint $table) { + $table->bigIncrements('id'); + $table->string('email')->unique(); + $table->bigInteger('policy_id')->unsigned(); + $table->smallInteger('priority')->default(7); // FIXME: do we need this at all? + + $table->foreign('policy_id')->references('id')->on('amavis_policy') + ->onDelete('cascade')->onUpdate('cascade'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('amavis_users'); + Schema::dropIfExists('amavis_policy'); + } +}; diff --git a/src/database/migrations/2023_02_14_100000_spamassassin_tables.php b/src/database/migrations/2023_02_14_100000_spamassassin_tables.php new file mode 100644 --- /dev/null +++ b/src/database/migrations/2023_02_14_100000_spamassassin_tables.php @@ -0,0 +1,37 @@ +bigIncrements('id'); + $table->string('username'); + $table->string('preference', 64); + $table->text('value'); + $table->timestamps(); + + $table->index('preference'); + $table->unique(['username', 'preference']); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('spamassassin_userprefs'); + } +}; diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php --- a/src/resources/lang/en/ui.php +++ b/src/resources/lang/en/ui.php @@ -537,9 +537,18 @@ 'search' => "User email address or name", 'search-pl' => "User ID, email or domain", 'skureq' => "{sku} requires {list}.", + 'spam' => "Spam", 'subscription' => "Subscription", 'subscriptions-none' => "This user has no subscriptions.", 'users' => "Users", + 'whitelist' => "Whitelist", + 'whitelist-text' => "Use it to whitelist senders who send mail that is often tagged (incorrectly) as spam. " + . "The list entries are file-glob-style patterns, so friend@somewhere.com, *@isp.com, or *.domain.net will all work. " + . "Specifically, * and ? are allowed, but all other metacharacters are not.", + 'blacklist' => "Blacklist", + 'blacklist-text' => "Use it to specify senders who send mail that is often tagged (incorrectly) as non-spam, but which you don't want. " + . "The list entries are file-glob-style patterns, so friend@somewhere.com, *@isp.com, or *.domain.net will all work. " + . "Specifically, * and ? are allowed, but all other metacharacters are not.", ], 'wallet' => [ diff --git a/src/resources/lang/en/validation.php b/src/resources/lang/en/validation.php --- a/src/resources/lang/en/validation.php +++ b/src/resources/lang/en/validation.php @@ -173,6 +173,7 @@ 'password-policy-max-len-error' => 'Maximum password length cannot be more than :max.', 'password-policy-last-error' => 'The minimum value for last N passwords is :last.', 'signuptokeninvalid' => 'The signup token is invalid.', + 'option-invalid-value' => 'Invalid option value.', /* |-------------------------------------------------------------------------- diff --git a/src/resources/themes/forms.scss b/src/resources/themes/forms.scss --- a/src/resources/themes/forms.scss +++ b/src/resources/themes/forms.scss @@ -19,6 +19,13 @@ } } + &.scroll { + margin: 0 -5px 0 -5px; + padding: 5px; + overflow-y: scroll; + max-height: 15em; + } + input.is-invalid { z-index: 2; } diff --git a/src/resources/vue/Admin/User.vue b/src/resources/vue/Admin/User.vue --- a/src/resources/vue/Admin/User.vue +++ b/src/resources/vue/Admin/User.vue @@ -224,6 +224,18 @@ {{ $t('btn.reset') }} +
+ +
+ {{ user.config.sa_whitelist_from.join(', ') }} +
+
+
+ +
+ {{ user.config.sa_blacklist_from.join(', ') }} +
+
diff --git a/src/resources/vue/User/Info.vue b/src/resources/vue/User/Info.vue --- a/src/resources/vue/User/Info.vue +++ b/src/resources/vue/User/Info.vue @@ -93,7 +93,7 @@
- + {{ $t('user.greylisting-text') }} @@ -105,7 +105,7 @@ {{ $t('dashboard.beta') }}
- + {{ $t('user.imapproxy-text') }} @@ -126,6 +126,29 @@ {{ $t('btn.submit') }}
+
+
+
+ +
+ + + {{ $t('user.whitelist-text') }} + +
+
+
+ +
+ + + {{ $t('user.blacklist-text') }} + +
+
+ {{ $t('btn.submit') }} +
+
@@ -256,6 +279,7 @@ if (this.isController) { tabs.push('form.settings') + tabs.push({ label: 'user.spam', beta: true }) } tabs.push('form.personal') @@ -399,21 +423,10 @@ }) }, submitSettings() { - this.$root.clearFormValidation($('#settings form')) - - let post = this.$root.pick(this.user.config, ['limit_geo']) - - const checklist = ['greylist_enabled', 'guam_enabled'] - checklist.forEach(name => { - if ($('#' + name).length) { - post[name] = $('#' + name).prop('checked') ? 1 : 0 - } - }) - - axios.post('/api/v4/users/' + this.user_id + '/config', post) - .then(response => { - this.$toast.success(response.data.message) - }) + this.postConfig('settings', ['limit_geo', 'greylist_enabled', 'guam_enabled']) + }, + submitSpamSettings() { + this.postConfig('spam', ['sa_whitelist_from', 'sa_blacklist_from']) }, statusUpdate(user) { this.user = Object.assign({}, this.user, user) @@ -432,6 +445,16 @@ } } }) + }, + postConfig(section, options) { + this.$root.clearFormValidation($('#' + section + ' form')) + + let post = this.$root.pick(this.user.config, options) + + axios.post('/api/v4/users/' + this.user_id + '/config', post) + .then(response => { + this.$toast.success(response.data.message) + }) } } } diff --git a/src/resources/vue/Widgets/Tabs.vue b/src/resources/vue/Widgets/Tabs.vue --- a/src/resources/vue/Widgets/Tabs.vue +++ b/src/resources/vue/Widgets/Tabs.vue @@ -7,6 +7,7 @@ :href="'#' + tabKey(tab)" > {{ $t(tabLabel(tab)) + (typeof tab != 'string' && 'count' in tab ? ` (${tab.count})` : '') }} + {{ $t('dashboard.beta') }} @@ -26,11 +27,12 @@ }, methods: { tabClick(event) { - event.preventDefault() + const target = $(event.target).closest('a')[0] + const key = target.id.replace('tab-', '') - new Tab(event.target).show() + event.preventDefault() - const key = event.target.id.replace('tab-', '') + new Tab(target).show() if (key in this.clickHandlers) { this.clickHandlers[key](event) diff --git a/src/tests/Browser/Admin/UserTest.php b/src/tests/Browser/Admin/UserTest.php --- a/src/tests/Browser/Admin/UserTest.php +++ b/src/tests/Browser/Admin/UserTest.php @@ -96,6 +96,10 @@ 'organization' => null, 'guam_enabled' => null, ]); + $jack->setConfig([ + 'sa_whitelist_from' => ['test1@domain.tld', 'test2'], + 'sa_blacklist_from' => ['test3@domain.tld', 'test4'], + ]); $event1 = EventLog::createFor($jack, EventLog::TYPE_SUSPENDED, 'Event 1'); $event2 = EventLog::createFor($jack, EventLog::TYPE_UNSUSPENDED, 'Event 2', ['test' => 'test-data']); @@ -210,13 +214,17 @@ $browser->assertSeeIn('@nav #tab-settings', 'Settings') ->click('@nav #tab-settings') ->whenAvailable('@user-settings form', function (Browser $browser) { - $browser->assertElementsCount('.row', 3) + $browser->assertElementsCount('.row', 5) ->assertSeeIn('.row:first-child label', 'Greylisting') ->assertSeeIn('.row:first-child .text-success', 'enabled') ->assertSeeIn('.row:nth-child(2) label', 'IMAP proxy') ->assertSeeIn('.row:nth-child(2) .text-danger', 'disabled') ->assertSeeIn('.row:nth-child(3) label', 'Geo-lockin') ->assertSeeIn('.row:nth-child(3) #limit_geo', 'No restrictions') + ->assertSeeIn('.row:nth-child(4) label', 'Whitelist') + ->assertSeeIn('.row:nth-child(4) #sa_whitelist_from', 'test1@domain.tld, test2') + ->assertSeeIn('.row:nth-child(5) label', 'Blacklist') + ->assertSeeIn('.row:nth-child(5) #sa_blacklist_from', 'test3@domain.tld, test4') ->assertMissing('#limit_geo + button'); }); diff --git a/src/tests/Browser/Pages/UserInfo.php b/src/tests/Browser/Pages/UserInfo.php --- a/src/tests/Browser/Pages/UserInfo.php +++ b/src/tests/Browser/Pages/UserInfo.php @@ -44,6 +44,7 @@ '@general' => '#general', '@personal' => '#personal', '@skus' => '#user-skus', + '@spam' => '#spam', '@status' => '#status-box', ]; } diff --git a/src/tests/Browser/UsersTest.php b/src/tests/Browser/UsersTest.php --- a/src/tests/Browser/UsersTest.php +++ b/src/tests/Browser/UsersTest.php @@ -72,6 +72,10 @@ $john = User::where('email', 'john@kolab.org')->first(); $john->setSettings($this->profile); + $john->setConfig([ + 'sa_whitelist_from' => [], + 'sa_blacklist_from' => [], + ]); UserAlias::where('user_id', $john->id) ->where('alias', 'john.test@kolab.org')->delete(); @@ -469,6 +473,64 @@ }); } + /** + * Test user page - Spam tab + * + * @depends testUserPersonalTab + */ + public function testUserSpam(): void + { + $john = $this->getTestUser('john@kolab.org'); + $john->setConfig([ + 'sa_whitelist_from' => ['testw'], + 'sa_blacklist_from' => ['testb'], + ]); + + $this->browse(function (Browser $browser) use ($john) { + $browser->visit('/user/' . $john->id) + ->on(new UserInfo()) + ->assertSeeIn('@nav #tab-spam', 'Spam') + ->click('@nav #tab-spam') + ->with('@spam form', function (Browser $browser) { + $browser->assertSeeIn('div.row:nth-child(1) label', 'Whitelist') + ->assertVisible('div.row:nth-child(1) small.text-muted') + ->with(new ListInput('#sa_whitelist_from'), function (Browser $browser) { + $browser->assertListInputValue(['testw']) + ->removeListEntry(1) + ->type('@input', 'a a'); + }) + ->assertSeeIn('div.row:nth-child(2) label', 'Blacklist') + ->assertVisible('div.row:nth-child(2) small.text-muted') + ->with(new ListInput('#sa_blacklist_from'), function (Browser $browser) { + $browser->assertListInputValue(['testb']) + ->removeListEntry(1) + ->type('@input', 'b b'); + }) + ->click('button[type=submit]') + ->assertToast(Toast::TYPE_ERROR, 'Form validation error') + ->with(new ListInput('#sa_whitelist_from'), function (Browser $browser) { + $browser->assertFormError(1, 'Invalid option value') + ->removeListEntry(1) + ->addListEntry('test1') + ->addListEntry('test2'); + }) + ->with(new ListInput('#sa_blacklist_from'), function (Browser $browser) { + $browser->assertFormError(1, 'Invalid option value') + ->removeListEntry(1) + ->addListEntry('test3') + ->addListEntry('test4'); + }) + ->click('button[type=submit]') + ->assertToast(Toast::TYPE_SUCCESS, 'User settings updated successfully.'); + }); + }); + + $config = $john->getConfig(); + + $this->assertSame(['test1', 'test2'], $config['sa_whitelist_from']); + $this->assertSame(['test3', 'test4'], $config['sa_blacklist_from']); + } + /** * Test user adding page */ @@ -484,6 +546,7 @@ ->on(new UserInfo()) ->assertSeeIn('#user-info .card-title', 'New user account') ->assertMissing('@nav #tab-settings') + ->assertMissing('@nav #tab-spam') ->assertMissing('@nav #tab-personal') ->with('@general', function (Browser $browser) { // Assert form content diff --git a/src/tests/Feature/Controller/UsersTest.php b/src/tests/Feature/Controller/UsersTest.php --- a/src/tests/Feature/Controller/UsersTest.php +++ b/src/tests/Feature/Controller/UsersTest.php @@ -84,6 +84,10 @@ $user->save(); Plan::withEnvTenantContext()->where('title', 'individual')->update(['mode' => 'email']); $user->setSettings(['plan_id' => null]); + $user->setConfig([ + 'sa_whitelist_from' => [], + 'sa_blacklist_from' => [], + ]); parent::tearDown(); } @@ -685,6 +689,10 @@ $john->setSetting('guam_enabled', null); $john->setSetting('password_policy', null); $john->setSetting('max_password_age', null); + $john->setConfig([ + 'sa_whitelist_from' => [], + 'sa_blacklist_from' => [], + ]); // Test unknown user id $post = ['greylist_enabled' => 1]; @@ -729,6 +737,8 @@ 'guam_enabled' => 1, 'password_policy' => 'min:10,max:255,upper,lower,digit,special', 'max_password_age' => 6, + 'sa_whitelist_from' => ['test1', 'test2'], + 'sa_blacklist_from' => ['test3', 'test4'], ]; $response = $this->actingAs($john)->post("/api/v4/users/{$john->id}/config", $post); @@ -740,10 +750,14 @@ $this->assertSame('success', $json['status']); $this->assertSame('User settings updated successfully.', $json['message']); - $this->assertSame('true', $john->getSetting('greylist_enabled')); - $this->assertSame('true', $john->getSetting('guam_enabled')); - $this->assertSame('min:10,max:255,upper,lower,digit,special', $john->getSetting('password_policy')); - $this->assertSame('6', $john->getSetting('max_password_age')); + $config = $john->getConfig(); + + $this->assertSame(true, $config['greylist_enabled']); + $this->assertSame(true, $config['guam_enabled']); + $this->assertSame('min:10,max:255,upper,lower,digit,special', $config['password_policy']); + $this->assertSame('6', $config['max_password_age']); + $this->assertSame(['test1', 'test2'], $config['sa_whitelist_from']); + $this->assertSame(['test3', 'test4'], $config['sa_blacklist_from']); // Test some valid data, acting as another account controller $ned = $this->getTestUser('ned@kolab.org'); diff --git a/src/tests/Feature/Traits/UserConfigTest.php b/src/tests/Feature/Traits/UserConfigTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Traits/UserConfigTest.php @@ -0,0 +1,248 @@ +deleteTestUser('user-test@' . \config('app.domain')); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('user-test@' . \config('app.domain')); + + parent::tearDown(); + } + + /** + * Test User::getConfig() and setConfig() methods for UserSettings + */ + public function testUserSettings(): void + { + $user = $this->getTestUser('user-test@' . \config('app.domain')); + $user->setSetting('greylist_enabled', null); + $user->setSetting('guam_enabled', null); + $user->setSetting('password_policy', null); + $user->setSetting('max_password_age', null); + $user->setSetting('limit_geo', null); + + // greylist_enabled + $this->assertSame(true, $user->getConfig()['greylist_enabled']); + + $result = $user->setConfig(['greylist_enabled' => false, 'unknown' => false]); + + $this->assertSame(['unknown' => "The requested configuration parameter is not supported."], $result); + $this->assertSame(false, $user->getConfig()['greylist_enabled']); + $this->assertSame('false', $user->getSetting('greylist_enabled')); + + $result = $user->setConfig(['greylist_enabled' => true]); + + $this->assertSame([], $result); + $this->assertSame(true, $user->getConfig()['greylist_enabled']); + $this->assertSame('true', $user->getSetting('greylist_enabled')); + + // guam_enabled + $this->assertSame(false, $user->getConfig()['guam_enabled']); + + $result = $user->setConfig(['guam_enabled' => false]); + + $this->assertSame([], $result); + $this->assertSame(false, $user->getConfig()['guam_enabled']); + $this->assertSame(null, $user->getSetting('guam_enabled')); + + $result = $user->setConfig(['guam_enabled' => true]); + + $this->assertSame([], $result); + $this->assertSame(true, $user->getConfig()['guam_enabled']); + $this->assertSame('true', $user->getSetting('guam_enabled')); + + // max_apssword_age + $this->assertSame(null, $user->getConfig()['max_password_age']); + + $result = $user->setConfig(['max_password_age' => -1]); + + $this->assertSame([], $result); + $this->assertSame(null, $user->getConfig()['max_password_age']); + $this->assertSame(null, $user->getSetting('max_password_age')); + + $result = $user->setConfig(['max_password_age' => 12]); + + $this->assertSame([], $result); + $this->assertSame('12', $user->getConfig()['max_password_age']); + $this->assertSame('12', $user->getSetting('max_password_age')); + + // password_policy + $result = $user->setConfig(['password_policy' => true]); + + $this->assertSame(['password_policy' => "Specified password policy is invalid."], $result); + $this->assertSame(null, $user->getConfig()['password_policy']); + $this->assertSame(null, $user->getSetting('password_policy')); + + $result = $user->setConfig(['password_policy' => 'min:-1']); + + $this->assertSame(['password_policy' => "Specified password policy is invalid."], $result); + + $result = $user->setConfig(['password_policy' => 'min:-1']); + + $this->assertSame(['password_policy' => "Specified password policy is invalid."], $result); + + $result = $user->setConfig(['password_policy' => 'min:10,unknown']); + + $this->assertSame(['password_policy' => "Specified password policy is invalid."], $result); + + \config(['app.password_policy' => 'min:5,max:100']); + $result = $user->setConfig(['password_policy' => 'min:4,max:255']); + + $this->assertSame(['password_policy' => "Minimum password length cannot be less than 5."], $result); + + \config(['app.password_policy' => 'min:5,max:100']); + $result = $user->setConfig(['password_policy' => 'min:10,max:255']); + + $this->assertSame(['password_policy' => "Maximum password length cannot be more than 100."], $result); + + \config(['app.password_policy' => 'min:5,max:255']); + $result = $user->setConfig(['password_policy' => 'min:10,max:255']); + + $this->assertSame([], $result); + $this->assertSame('min:10,max:255', $user->getConfig()['password_policy']); + $this->assertSame('min:10,max:255', $user->getSetting('password_policy')); + + // limit_geo + $this->assertSame([], $user->getConfig()['limit_geo']); + + $result = $user->setConfig(['limit_geo' => '']); + + $err = "Specified configuration is invalid. Expected a list of two-letter country codes."; + $this->assertSame(['limit_geo' => $err], $result); + $this->assertSame(null, $user->getSetting('limit_geo')); + + $result = $user->setConfig(['limit_geo' => ['usa']]); + + $this->assertSame(['limit_geo' => $err], $result); + $this->assertSame(null, $user->getSetting('limit_geo')); + + $result = $user->setConfig(['limit_geo' => []]); + + $this->assertSame([], $result); + $this->assertSame(null, $user->getSetting('limit_geo')); + + $result = $user->setConfig(['limit_geo' => ['US', 'ru']]); + + $this->assertSame([], $result); + $this->assertSame(['US', 'RU'], $user->getConfig()['limit_geo']); + $this->assertSame('["US","RU"]', $user->getSetting('limit_geo')); + } + + /** + * Test User::getConfig()/setConfig() methods with Amavis settings + */ + public function testAmavisOptions(): void + { + $this->markTestIncomplete(); + } + + /** + * Test User::getConfig()/setConfig() methods with Spamassassin settings + */ + public function testSpamassassinOptions(): void + { + $user = $this->getTestUser('user-test@' . \config('app.domain')); + + SpamPref::where('username', $user->email)->delete(); + + // whitelist_from + $this->assertSame([], $user->getConfig()['sa_whitelist_from']); + + $result = $user->setConfig(['sa_whitelist_from' => []]); + + $this->assertSame([], $result); + $this->assertSame([], $user->getConfig()['sa_whitelist_from']); + + $whitelist = ['test@test.com', '*@test.pl', '*.domain.net']; + $result = $user->setConfig(['sa_whitelist_from' => $whitelist]); + + $this->assertSame([], $result); + $this->assertSame($whitelist, $user->getConfig()['sa_whitelist_from']); + + $whitelist[] = 'test test'; + $result = $user->setConfig(['sa_whitelist_from' => $whitelist]); + + $this->assertSame(['sa_whitelist_from' => [3 => "Invalid option value."]], $result); + unset($whitelist[3]); + $this->assertSame($whitelist, $user->getConfig()['sa_whitelist_from']); + + $result = $user->setConfig(['sa_whitelist_from' => []]); + + $this->assertSame([], $result); + $this->assertSame([], $user->getConfig()['sa_whitelist_from']); + + // blacklist_from + $this->assertSame([], $user->getConfig()['sa_blacklist_from']); + + $result = $user->setConfig(['sa_blacklist_from' => []]); + + $this->assertSame([], $result); + $this->assertSame([], $user->getConfig()['sa_blacklist_from']); + + $blacklist = ['test@test.com', '*@test.pl', '*.domain.net']; + $result = $user->setConfig(['sa_blacklist_from' => $whitelist]); + + $this->assertSame([], $result); + $this->assertSame($blacklist, $user->getConfig()['sa_blacklist_from']); + + $blacklist[] = 'test test'; + $result = $user->setConfig(['sa_blacklist_from' => $blacklist]); + + $this->assertSame(['sa_blacklist_from' => [3 => "Invalid option value."]], $result); + unset($blacklist[3]); + $this->assertSame($blacklist, $user->getConfig()['sa_blacklist_from']); + + $result = $user->setConfig(['sa_blacklist_from' => []]); + + $this->assertSame([], $result); + $this->assertSame([], $user->getConfig()['sa_blacklist_from']); + } + + /** + * Test UserConfigTrait actions on user deletion + */ + public function testUserDelete(): void + { + Queue::fake(); + + $user = $this->getTestUser('user-test@' . \config('app.domain')); + + AmavisUser::query()->delete(); + AmavisPolicy::query()->delete(); + SpamPref::saveFor($user->email, ['whitelist_from' => ['test']]); + AmavisPolicy::saveFor($user->email, ['spam_tag_level' => 5]); + + $this->assertSame(1, SpamPref::where('username', $user->email)->count()); + $this->assertSame(1, AmavisUser::where('email', $user->email)->count()); + $this->assertSame(1, AmavisPolicy::count()); + + $user->delete(); + + $this->assertTrue($user->fresh()->trashed()); + $this->assertSame(0, SpamPref::where('username', $user->email)->count()); + $this->assertSame(0, AmavisUser::where('email', $user->email)->count()); + $this->assertSame(0, AmavisPolicy::count()); + } +} 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 @@ -504,125 +504,6 @@ $this->assertNotContains($domain->namespace, $domains); } - /** - * Test User::getConfig() and setConfig() methods - */ - public function testConfigTrait(): void - { - $user = $this->getTestUser('UserAccountA@UserAccount.com'); - $user->setSetting('greylist_enabled', null); - $user->setSetting('guam_enabled', null); - $user->setSetting('password_policy', null); - $user->setSetting('max_password_age', null); - $user->setSetting('limit_geo', null); - - // greylist_enabled - $this->assertSame(true, $user->getConfig()['greylist_enabled']); - - $result = $user->setConfig(['greylist_enabled' => false, 'unknown' => false]); - - $this->assertSame(['unknown' => "The requested configuration parameter is not supported."], $result); - $this->assertSame(false, $user->getConfig()['greylist_enabled']); - $this->assertSame('false', $user->getSetting('greylist_enabled')); - - $result = $user->setConfig(['greylist_enabled' => true]); - - $this->assertSame([], $result); - $this->assertSame(true, $user->getConfig()['greylist_enabled']); - $this->assertSame('true', $user->getSetting('greylist_enabled')); - - // guam_enabled - $this->assertSame(false, $user->getConfig()['guam_enabled']); - - $result = $user->setConfig(['guam_enabled' => false]); - - $this->assertSame([], $result); - $this->assertSame(false, $user->getConfig()['guam_enabled']); - $this->assertSame(null, $user->getSetting('guam_enabled')); - - $result = $user->setConfig(['guam_enabled' => true]); - - $this->assertSame([], $result); - $this->assertSame(true, $user->getConfig()['guam_enabled']); - $this->assertSame('true', $user->getSetting('guam_enabled')); - - // max_apssword_age - $this->assertSame(null, $user->getConfig()['max_password_age']); - - $result = $user->setConfig(['max_password_age' => -1]); - - $this->assertSame([], $result); - $this->assertSame(null, $user->getConfig()['max_password_age']); - $this->assertSame(null, $user->getSetting('max_password_age')); - - $result = $user->setConfig(['max_password_age' => 12]); - - $this->assertSame([], $result); - $this->assertSame('12', $user->getConfig()['max_password_age']); - $this->assertSame('12', $user->getSetting('max_password_age')); - - // password_policy - $result = $user->setConfig(['password_policy' => true]); - - $this->assertSame(['password_policy' => "Specified password policy is invalid."], $result); - $this->assertSame(null, $user->getConfig()['password_policy']); - $this->assertSame(null, $user->getSetting('password_policy')); - - $result = $user->setConfig(['password_policy' => 'min:-1']); - - $this->assertSame(['password_policy' => "Specified password policy is invalid."], $result); - - $result = $user->setConfig(['password_policy' => 'min:-1']); - - $this->assertSame(['password_policy' => "Specified password policy is invalid."], $result); - - $result = $user->setConfig(['password_policy' => 'min:10,unknown']); - - $this->assertSame(['password_policy' => "Specified password policy is invalid."], $result); - - \config(['app.password_policy' => 'min:5,max:100']); - $result = $user->setConfig(['password_policy' => 'min:4,max:255']); - - $this->assertSame(['password_policy' => "Minimum password length cannot be less than 5."], $result); - - \config(['app.password_policy' => 'min:5,max:100']); - $result = $user->setConfig(['password_policy' => 'min:10,max:255']); - - $this->assertSame(['password_policy' => "Maximum password length cannot be more than 100."], $result); - - \config(['app.password_policy' => 'min:5,max:255']); - $result = $user->setConfig(['password_policy' => 'min:10,max:255']); - - $this->assertSame([], $result); - $this->assertSame('min:10,max:255', $user->getConfig()['password_policy']); - $this->assertSame('min:10,max:255', $user->getSetting('password_policy')); - - // limit_geo - $this->assertSame([], $user->getConfig()['limit_geo']); - - $result = $user->setConfig(['limit_geo' => '']); - - $err = "Specified configuration is invalid. Expected a list of two-letter country codes."; - $this->assertSame(['limit_geo' => $err], $result); - $this->assertSame(null, $user->getSetting('limit_geo')); - - $result = $user->setConfig(['limit_geo' => ['usa']]); - - $this->assertSame(['limit_geo' => $err], $result); - $this->assertSame(null, $user->getSetting('limit_geo')); - - $result = $user->setConfig(['limit_geo' => []]); - - $this->assertSame([], $result); - $this->assertSame(null, $user->getSetting('limit_geo')); - - $result = $user->setConfig(['limit_geo' => ['US', 'ru']]); - - $this->assertSame([], $result); - $this->assertSame(['US', 'RU'], $user->getConfig()['limit_geo']); - $this->assertSame('["US","RU"]', $user->getSetting('limit_geo')); - } - /** * Test user account degradation and un-degradation */