diff --git a/src/app/Console/Commands/Domain/SuspendCommand.php b/src/app/Console/Commands/Domain/SuspendCommand.php --- a/src/app/Console/Commands/Domain/SuspendCommand.php +++ b/src/app/Console/Commands/Domain/SuspendCommand.php @@ -11,7 +11,7 @@ * * @var string */ - protected $signature = 'domain:suspend {domain}'; + protected $signature = 'domain:suspend {domain} {--comment=}'; /** * The console command description. @@ -35,5 +35,7 @@ } $domain->suspend(); + + \App\EventLog::createFor($domain, \App\EventLog::TYPE_SUSPENDED, $this->option('comment')); } } diff --git a/src/app/Console/Commands/Domain/UnsuspendCommand.php b/src/app/Console/Commands/Domain/UnsuspendCommand.php --- a/src/app/Console/Commands/Domain/UnsuspendCommand.php +++ b/src/app/Console/Commands/Domain/UnsuspendCommand.php @@ -11,7 +11,7 @@ * * @var string */ - protected $signature = 'domain:unsuspend {domain}'; + protected $signature = 'domain:unsuspend {domain} {--comment=}'; /** * The console command description. @@ -35,5 +35,7 @@ } $domain->unsuspend(); + + \App\EventLog::createFor($domain, \App\EventLog::TYPE_UNSUSPENDED, $this->option('comment')); } } diff --git a/src/app/Console/Commands/Group/SuspendCommand.php b/src/app/Console/Commands/Group/SuspendCommand.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/Group/SuspendCommand.php @@ -0,0 +1,41 @@ +<?php + +namespace App\Console\Commands\Group; + +use App\Console\Command; + +class SuspendCommand extends Command +{ + /** + * The name and signature of the console command. + * + * @var string + */ + protected $signature = 'group:suspend {group} {--comment=}'; + + /** + * The console command description. + * + * @var string + */ + protected $description = 'Suspend a distribution list (group)'; + + /** + * Execute the console command. + * + * @return mixed + */ + public function handle() + { + $group = $this->getGroup($this->argument('group')); + + if (!$group) { + $this->error("Group not found."); + return 1; + } + + $group->suspend(); + + \App\EventLog::createFor($group, \App\EventLog::TYPE_SUSPENDED, $this->option('comment')); + } +} diff --git a/src/app/Console/Commands/Group/UnsuspendCommand.php b/src/app/Console/Commands/Group/UnsuspendCommand.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/Group/UnsuspendCommand.php @@ -0,0 +1,41 @@ +<?php + +namespace App\Console\Commands\Group; + +use App\Console\Command; + +class UnsuspendCommand extends Command +{ + /** + * The name and signature of the console command. + * + * @var string + */ + protected $signature = 'group:unsuspend {group} {--comment=}'; + + /** + * The console command description. + * + * @var string + */ + protected $description = 'Remove a group suspension'; + + /** + * Execute the console command. + * + * @return mixed + */ + public function handle() + { + $group = $this->getGroup($this->argument('group')); + + if (!$group) { + $this->error("Group not found."); + return 1; + } + + $group->unsuspend(); + + \App\EventLog::createFor($group, \App\EventLog::TYPE_UNSUSPENDED, $this->option('comment')); + } +} diff --git a/src/app/Console/Commands/User/SuspendCommand.php b/src/app/Console/Commands/User/SuspendCommand.php --- a/src/app/Console/Commands/User/SuspendCommand.php +++ b/src/app/Console/Commands/User/SuspendCommand.php @@ -11,7 +11,7 @@ * * @var string */ - protected $signature = 'user:suspend {user}'; + protected $signature = 'user:suspend {user} {--comment=}'; /** * The console command description. @@ -35,5 +35,7 @@ } $user->suspend(); + + \App\EventLog::createFor($user, \App\EventLog::TYPE_SUSPENDED, $this->option('comment')); } } diff --git a/src/app/Console/Commands/User/UnsuspendCommand.php b/src/app/Console/Commands/User/UnsuspendCommand.php --- a/src/app/Console/Commands/User/UnsuspendCommand.php +++ b/src/app/Console/Commands/User/UnsuspendCommand.php @@ -11,7 +11,7 @@ * * @var string */ - protected $signature = 'user:unsuspend {user}'; + protected $signature = 'user:unsuspend {user} {--comment=}'; /** * The console command description. @@ -35,5 +35,7 @@ } $user->unsuspend(); + + \App\EventLog::createFor($user, \App\EventLog::TYPE_UNSUSPENDED, $this->option('comment')); } } diff --git a/src/app/EventLog.php b/src/app/EventLog.php new file mode 100644 --- /dev/null +++ b/src/app/EventLog.php @@ -0,0 +1,131 @@ +<?php + +namespace App; + +use App\Traits\UuidStrKeyTrait; +use Dyrynda\Database\Support\NullableFields; +use Illuminate\Database\Eloquent\Model; + +/** + * The eloquent definition of an EventLog record. + * + * @property ?string $comment Optional event description + * @property ?array $data Optional event data + * @property string $id Log record identifier + * @property string $object_id Object identifier + * @property string $object_type Object type (class) + * @property int $type Event type (0-255) + * @property ?string $user_email Acting user email + */ +class EventLog extends Model +{ + use NullableFields; + use UuidStrKeyTrait; + + public const TYPE_SUSPENDED = 1; + public const TYPE_UNSUSPENDED = 2; + public const TYPE_COMMENT = 3; + + /** @var array<int, string> The attributes that are mass assignable */ + protected $fillable = [ + 'comment', + // extra event info (json) + 'data', + 'type', + // user, domain, etc. + 'object_id', + 'object_type', + // actor, if any + 'user_email', + ]; + + /** @var array<string, string> Casts properties as type */ + protected $casts = [ + 'created_at' => 'datetime:Y-m-d H:i:s', + 'data' => 'array', + 'type' => 'integer', + ]; + + /** @var array<int, string> The attributes that can be not set */ + protected $nullable = ['comment', 'data', 'user_email']; + + /** @var string Database table name */ + protected $table = 'eventlog'; + + /** @var bool Indicates if the model should be timestamped. */ + public $timestamps = false; + + + /** + * Create an eventlog object for a specified object. + * + * @param object $object Object (User, Domain, etc.) + * @param int $type Event type (use one of EventLog::TYPE_* consts) + * @param ?string $comment Event description + * @param ?array $data Extra information + * + * @return EventLog + */ + public static function createFor($object, int $type, string $comment = null, array $data = null): EventLog + { + $event = self::create([ + 'object_id' => $object->id, + 'object_type' => get_class($object), + 'type' => $type, + 'comment' => $comment, + 'data' => $data, + ]); + + return $event; + } + + /** + * Principally an object such as Domain, User, Group. + * Note that it may be trashed (soft-deleted). + * + * @return mixed + */ + public function object() + { + return $this->morphTo()->withTrashed(); // @phpstan-ignore-line + } + + /** + * Get an event type name. + * + * @return ?string Event type name + */ + public function eventName(): ?string + { + switch ($this->type) { + case self::TYPE_SUSPENDED: + return \trans('app.event-suspended'); + case self::TYPE_UNSUSPENDED: + return \trans('app.event-unsuspended'); + case self::TYPE_COMMENT: + return \trans('app.event-comment'); + default: + return null; + } + } + + /** + * Event type mutator + * + * @throws \Exception + */ + public function setTypeAttribute($type) + { + if (!is_numeric($type)) { + throw new \Exception("Expecting an event type to be numeric"); + } + + $type = (int) $type; + + if ($type < 0 || $type > 255) { + throw new \Exception("Expecting an event type between 0 and 255"); + } + + $this->attributes['type'] = $type; + } +} diff --git a/src/app/Http/Controllers/API/V4/Admin/DomainsController.php b/src/app/Http/Controllers/API/V4/Admin/DomainsController.php --- a/src/app/Http/Controllers/API/V4/Admin/DomainsController.php +++ b/src/app/Http/Controllers/API/V4/Admin/DomainsController.php @@ -3,8 +3,10 @@ namespace App\Http\Controllers\API\V4\Admin; use App\Domain; +use App\EventLog; use App\User; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Validator; class DomainsController extends \App\Http\Controllers\API\V4\DomainsController { @@ -94,8 +96,16 @@ return $this->errorResponse(404); } + $v = Validator::make($request->all(), ['comment' => 'nullable|string|max:1024']); + + if ($v->fails()) { + return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); + } + $domain->suspend(); + EventLog::createFor($domain, EventLog::TYPE_SUSPENDED, $request->comment); + return response()->json([ 'status' => 'success', 'message' => self::trans('app.domain-suspend-success'), @@ -118,8 +128,16 @@ return $this->errorResponse(404); } + $v = Validator::make($request->all(), ['comment' => 'nullable|string|max:1024']); + + if ($v->fails()) { + return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); + } + $domain->unsuspend(); + EventLog::createFor($domain, EventLog::TYPE_UNSUSPENDED, $request->comment); + return response()->json([ 'status' => 'success', 'message' => self::trans('app.domain-unsuspend-success'), diff --git a/src/app/Http/Controllers/API/V4/Admin/EventLogController.php b/src/app/Http/Controllers/API/V4/Admin/EventLogController.php new file mode 100644 --- /dev/null +++ b/src/app/Http/Controllers/API/V4/Admin/EventLogController.php @@ -0,0 +1,67 @@ +<?php + +namespace App\Http\Controllers\API\V4\Admin; + +use App\EventLog; +use App\Http\Controllers\Controller; +use Illuminate\Http\Request; + +class EventLogController extends Controller +{ + /** + * Listing of eventlog entries. + * + * @param \Illuminate\Http\Request $request HTTP Request + * @param string $object_type Object type + * @param string $object_id Object id + * + * @return \Illuminate\Http\JsonResponse + */ + public function index(Request $request, string $object_type, string $object_id) + { + $object_type = "App\\" . ucfirst($object_type); + + if (!class_exists($object_type)) { + return $this->errorResponse(404); + } + + $object = (new $object_type)->find($object_id); + + if (!$this->checkTenant($object)) { + return $this->errorResponse(404); + } + + $page = intval($request->input('page')) ?: 1; + $pageSize = 20; + $hasMore = false; + + $result = EventLog::where('object_id', $object_id) + ->where('object_type', $object_type) + ->orderBy('created_at', 'desc') + ->limit($pageSize + 1) + ->offset($pageSize * ($page - 1)) + ->get(); + + if (count($result) > $pageSize) { + $result->pop(); + $hasMore = true; + } + + $result = $result->map(function ($event) { + return [ + 'id' => $event->id, + 'comment' => $event->comment, + 'createdAt' => $event->created_at->toDateTimeString(), + 'event' => $event->eventName(), + 'data' => $event->data, + 'user' => $event->user_email, + ]; + }); + + return response()->json([ + 'list' => $result, + 'count' => count($result), + 'hasMore' => $hasMore, + ]); + } +} diff --git a/src/app/Http/Controllers/API/V4/Admin/GroupsController.php b/src/app/Http/Controllers/API/V4/Admin/GroupsController.php --- a/src/app/Http/Controllers/API/V4/Admin/GroupsController.php +++ b/src/app/Http/Controllers/API/V4/Admin/GroupsController.php @@ -2,9 +2,11 @@ namespace App\Http\Controllers\API\V4\Admin; +use App\EventLog; use App\Group; use App\User; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Validator; class GroupsController extends \App\Http\Controllers\API\V4\GroupsController { @@ -73,8 +75,16 @@ return $this->errorResponse(404); } + $v = Validator::make($request->all(), ['comment' => 'nullable|string|max:1024']); + + if ($v->fails()) { + return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); + } + $group->suspend(); + EventLog::createFor($group, EventLog::TYPE_SUSPENDED, $request->comment); + return response()->json([ 'status' => 'success', 'message' => self::trans('app.distlist-suspend-success'), @@ -97,8 +107,16 @@ return $this->errorResponse(404); } + $v = Validator::make($request->all(), ['comment' => 'nullable|string|max:1024']); + + if ($v->fails()) { + return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); + } + $group->unsuspend(); + EventLog::createFor($group, EventLog::TYPE_UNSUSPENDED, $request->comment); + return response()->json([ 'status' => 'success', 'message' => self::trans('app.distlist-unsuspend-success'), diff --git a/src/app/Http/Controllers/API/V4/Admin/UsersController.php b/src/app/Http/Controllers/API/V4/Admin/UsersController.php --- a/src/app/Http/Controllers/API/V4/Admin/UsersController.php +++ b/src/app/Http/Controllers/API/V4/Admin/UsersController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\API\V4\Admin; use App\Domain; +use App\EventLog; use App\Sku; use App\User; use App\Wallet; @@ -321,8 +322,16 @@ return $this->errorResponse(403); } + $v = Validator::make($request->all(), ['comment' => 'nullable|string|max:1024']); + + if ($v->fails()) { + return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); + } + $user->suspend(); + EventLog::createFor($user, EventLog::TYPE_SUSPENDED, $request->comment); + return response()->json([ 'status' => 'success', 'message' => self::trans('app.user-suspend-success'), @@ -349,8 +358,16 @@ return $this->errorResponse(403); } + $v = Validator::make($request->all(), ['comment' => 'nullable|string|max:1024']); + + if ($v->fails()) { + return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); + } + $user->unsuspend(); + EventLog::createFor($user, EventLog::TYPE_UNSUSPENDED, $request->comment); + return response()->json([ 'status' => 'success', 'message' => self::trans('app.user-unsuspend-success'), diff --git a/src/app/Http/Controllers/API/V4/Reseller/EventLogController.php b/src/app/Http/Controllers/API/V4/Reseller/EventLogController.php new file mode 100644 --- /dev/null +++ b/src/app/Http/Controllers/API/V4/Reseller/EventLogController.php @@ -0,0 +1,7 @@ +<?php + +namespace App\Http\Controllers\API\V4\Reseller; + +class EventLogController extends \App\Http\Controllers\API\V4\Admin\EventLogController +{ +} diff --git a/src/app/Jobs/User/CreateJob.php b/src/app/Jobs/User/CreateJob.php --- a/src/app/Jobs/User/CreateJob.php +++ b/src/app/Jobs/User/CreateJob.php @@ -76,6 +76,8 @@ $code = \Artisan::call("user:abuse-check {$this->userId}"); if ($code == 2) { \Log::info("Suspending user due to suspected abuse: {$this->userId} {$user->email}"); + \App\EventLog::createFor($user, \App\EventLog::TYPE_SUSPENDED, "Suspected spammer"); + $user->status |= \App\User::STATUS_SUSPENDED; } } diff --git a/src/app/Observers/DomainObserver.php b/src/app/Observers/DomainObserver.php --- a/src/app/Observers/DomainObserver.php +++ b/src/app/Observers/DomainObserver.php @@ -45,6 +45,9 @@ public function deleted(Domain $domain) { if ($domain->isForceDeleting()) { + // Remove EventLog records + \App\EventLog::where('object_id', $domain->id)->where('object_type', Domain::class)->delete(); + return; } diff --git a/src/app/Observers/EventLogObserver.php b/src/app/Observers/EventLogObserver.php new file mode 100644 --- /dev/null +++ b/src/app/Observers/EventLogObserver.php @@ -0,0 +1,24 @@ +<?php + +namespace App\Observers; + +use App\EventLog; + +class EventLogObserver +{ + /** + * Ensure the event entry ID is a custom ID (uuid). + * + * @param \App\EventLog $eventlog The EventLog object + */ + public function creating(EventLog $eventlog): void + { + if (!isset($eventlog->user_email)) { + $eventlog->user_email = \App\Utils::userEmailOrNull(); + } + + if (!isset($eventlog->type)) { + throw new \Exception("Unset event type"); + } + } +} diff --git a/src/app/Observers/GroupObserver.php b/src/app/Observers/GroupObserver.php --- a/src/app/Observers/GroupObserver.php +++ b/src/app/Observers/GroupObserver.php @@ -45,6 +45,9 @@ public function deleted(Group $group) { if ($group->isForceDeleting()) { + // Remove EventLog records + \App\EventLog::where('object_id', $group->id)->where('object_type', Group::class)->delete(); + return; } 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 @@ -224,7 +224,7 @@ } /** - * Remove entitleables/transactions related to the user (in user's wallets) + * Remove entities related to the user (in user's wallets), entitlements, transactions, etc. * * @param \App\User $user The user * @param bool $force Force-delete mode @@ -261,6 +261,9 @@ \App\Transaction::where('object_type', Wallet::class) ->whereIn('object_id', $wallets) ->delete(); + + // Remove EventLog records + \App\EventLog::where('object_id', $user->id)->where('object_type', User::class)->delete(); } // regardless of force delete, we're always purging whitelists... just in case diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php --- a/src/app/Providers/AppServiceProvider.php +++ b/src/app/Providers/AppServiceProvider.php @@ -69,6 +69,7 @@ { \App\Domain::observe(\App\Observers\DomainObserver::class); \App\Entitlement::observe(\App\Observers\EntitlementObserver::class); + \App\EventLog::observe(\App\Observers\EventLogObserver::class); \App\Group::observe(\App\Observers\GroupObserver::class); \App\GroupSetting::observe(\App\Observers\GroupSettingObserver::class); \App\Meet\Room::observe(\App\Observers\Meet\RoomObserver::class); diff --git a/src/database/migrations/2023_06_06_100000_create_eventlog_table.php b/src/database/migrations/2023_06_06_100000_create_eventlog_table.php new file mode 100644 --- /dev/null +++ b/src/database/migrations/2023_06_06_100000_create_eventlog_table.php @@ -0,0 +1,43 @@ +<?php + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + /** + * Run the migrations. + * + * @return void + */ + public function up() + { + Schema::create( + 'eventlog', + function (Blueprint $table) { + $table->string('id', 36)->primary(); + $table->string('object_id', 36); + $table->string('object_type', 36); + $table->tinyInteger('type')->unsigned(); + $table->string('user_email')->nullable(); + $table->string('comment', 1024)->nullable(); + $table->text('data')->nullable(); // json + $table->timestamp('created_at')->useCurrent(); + + $table->index(['object_id', 'object_type', 'type']); + $table->index('created_at'); + } + ); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('eventlog'); + } +}; diff --git a/src/resources/lang/en/app.php b/src/resources/lang/en/app.php --- a/src/resources/lang/en/app.php +++ b/src/resources/lang/en/app.php @@ -23,6 +23,10 @@ 'companion-create-success' => 'Companion app has been created.', 'companion-delete-success' => 'Companion app has been removed.', + 'event-suspended' => 'Suspended', + 'event-unsuspended' => 'Unsuspended', + 'event-comment' => 'Commented', + 'mandate-delete-success' => 'The auto-payment has been removed.', 'mandate-update-success' => 'The auto-payment has been updated.', 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 @@ -42,6 +42,12 @@ 'verify' => "Verify", ], + 'collection' => [ + 'create' => "Create collection", + 'new' => "New Collection", + 'name' => "Name", + ], + 'companion' => [ 'title' => "Companion Apps", 'companion' => "Companion App", @@ -167,12 +173,6 @@ . "to the file via a unique link.", ], - 'collection' => [ - 'create' => "Create collection", - 'new' => "New Collection", - 'name' => "Name", - ], - 'form' => [ 'acl' => "Access rights", 'acl-full' => "All", @@ -182,6 +182,7 @@ 'anyone' => "Anyone", 'code' => "Confirmation Code", 'config' => "Configuration", + 'comment' => "Comment", 'companion' => "Companion App", 'date' => "Date", 'description' => "Description", @@ -195,8 +196,10 @@ 'general' => "General", 'geolocation' => "Your current location: {location}", 'lastname' => "Last Name", + 'less' => "Less", 'name' => "Name", 'months' => "months", + 'more' => "More", 'none' => "none", 'norestrictions' => "No restrictions", 'or' => "or", @@ -242,6 +245,12 @@ 'it' => "Italian", ], + 'log' => [ + 'event' => "Event", + 'list-none' => "There's no events in the log", + 'history' => "History", + ], + 'login' => [ '2fa' => "Second factor code", '2fa_desc' => "Second factor code is optional for users with no 2-Factor Authentication setup.", diff --git a/src/resources/themes/app.scss b/src/resources/themes/app.scss --- a/src/resources/themes/app.scss +++ b/src/resources/themes/app.scss @@ -205,6 +205,31 @@ width: 50px; } } + + &.eventlog { + .details, + .btn-less { + display: none; + } + + tr.open { + .btn-more { + display: none; + } + + .details { + display: block; + } + + .btn-less { + display: initial; + } + } + + td.description { + width: 98%; + } + } } .table > :not(:first-child) { diff --git a/src/resources/vue/Admin/Distlist.vue b/src/resources/vue/Admin/Distlist.vue --- a/src/resources/vue/Admin/Distlist.vue +++ b/src/resources/vue/Admin/Distlist.vue @@ -1,6 +1,6 @@ <template> - <div v-if="list.id" class="container"> - <div class="card" id="distlist-info"> + <div class="container"> + <div v-if="list.id" class="card" id="distlist-info"> <div class="card-body"> <div class="card-title">{{ list.email }}</div> <div class="card-text"> @@ -37,18 +37,15 @@ </div> </form> <div class="mt-2 buttons"> - <button v-if="!list.isSuspended" id="button-suspend" class="btn btn-warning" type="button" @click="suspendList"> - {{ $t('btn.suspend') }} - </button> - <button v-if="list.isSuspended" id="button-unsuspend" class="btn btn-warning" type="button" @click="unsuspendList"> - {{ $t('btn.unsuspend') }} - </button> + <btn :id="`button-${suspendAction}`" class="btn-outline-primary" @click="setSuspendState"> + {{ $t(`btn.${suspendAction}`) }} + </btn> </div> </div> </div> </div> - <tabs class="mt-3" :tabs="['form.settings']"></tabs> - <div class="tab-content"> + <tabs class="mt-3" :tabs="['form.settings', 'log.history']" ref="tabs"></tabs> + <div v-if="list.id" class="tab-content"> <div class="tab-pane show active" id="settings" role="tabpanel" aria-labelledby="tab-settings"> <div class="card-body"> <div class="card-text"> @@ -65,15 +62,38 @@ </div> </div> </div> + <div class="tab-pane" id="history" role="tabpanel" aria-labelledby="tab-history"> + <div class="card-body"> + <event-log v-if="loadEventLog" :object-id="list.id" object-type="group" ref="eventLog" class="card-text mb-0"></event-log> + </div> + </div> </div> + + <modal-dialog id="suspend-dialog" ref="suspendDialog" :title="$t(`btn.${suspendAction}`)" @click="submitSuspend()" :buttons="['submit']"> + <textarea v-model="comment" name="comment" class="form-control" :placeholder="$t('form.comment')" rows="3"></textarea> + </modal-dialog> </div> </template> <script> + import EventLog from '../Widgets/EventLog' + import ModalDialog from '../Widgets/ModalDialog' + export default { + components: { + EventLog, + ModalDialog + }, data() { return { - list: { members: [], config: {} } + comment: '', + list: { members: [], config: {} }, + loadEventLog: false + } + }, + computed: { + suspendAction() { + return this.list.isSuspended ? 'unsuspend' : 'suspend' } }, created() { @@ -83,22 +103,29 @@ }) .catch(this.$root.errorHandler) }, + mounted() { + this.$refs.tabs.clickHandler('history', () => { this.loadEventLog = true }) + }, methods: { - suspendList() { - axios.post('/api/v4/groups/' + this.list.id + '/suspend') - .then(response => { - if (response.data.status == 'success') { - this.$toast.success(response.data.message) - this.list = Object.assign({}, this.list, { isSuspended: true }) - } - }) + setSuspendState() { + this.$root.clearFormValidation($('#suspend-dialog')) + this.$refs.suspendDialog.show() }, - unsuspendList() { - axios.post('/api/v4/groups/' + this.list.id + '/unsuspend') + submitSuspend() { + const post = { comment: this.comment } + + axios.post(`/api/v4/groups/${this.list.id}/${this.suspendAction}`, post) .then(response => { if (response.data.status == 'success') { this.$toast.success(response.data.message) - this.list = Object.assign({}, this.list, { isSuspended: false }) + this.list = Object.assign({}, this.list, { isSuspended: !this.list.isSuspended }) + + this.$refs.suspendDialog.hide() + this.comment = '' + + if (this.loadEventLog) { + this.$refs.eventLog.loadLog({ reset: true }) + } } }) } diff --git a/src/resources/vue/Admin/Domain.vue b/src/resources/vue/Admin/Domain.vue --- a/src/resources/vue/Admin/Domain.vue +++ b/src/resources/vue/Admin/Domain.vue @@ -1,5 +1,5 @@ <template> - <div v-if="domain" class="container"> + <div class="container"> <div class="card" id="domain-info"> <div class="card-body"> <div class="card-title">{{ domain.namespace }}</div> @@ -25,19 +25,16 @@ </div> </form> <div class="mt-2 buttons"> - <btn v-if="!domain.isSuspended" id="button-suspend" class="btn-warning" @click="suspendDomain"> - {{ $t('btn.suspend') }} - </btn> - <btn v-if="domain.isSuspended" id="button-unsuspend" class="btn-warning" @click="unsuspendDomain"> - {{ $t('btn.unsuspend') }} + <btn :id="`button-${suspendAction}`" class="btn-outline-primary" @click="setSuspendState"> + {{ $t(`btn.${suspendAction}`) }} </btn> </div> </div> </div> </div> - <tabs class="mt-3" :tabs="['form.config', 'form.settings']"></tabs> + <tabs class="mt-3" :tabs="['form.config', 'form.settings', 'log.history']" ref="tabs"></tabs> <div class="tab-content"> - <div class="tab-pane show active" id="config" role="tabpanel" aria-labelledby="tab-config"> + <div v-if="domain.id" class="tab-pane show active" id="config" role="tabpanel" aria-labelledby="tab-config"> <div class="card-body"> <div class="card-text"> <p>{{ $t('domain.dns-verify') }}</p> @@ -47,7 +44,7 @@ </div> </div> </div> - <div class="tab-pane" id="settings" role="tabpanel" aria-labelledby="tab-settings"> + <div v-if="domain.id" class="tab-pane" id="settings" role="tabpanel" aria-labelledby="tab-settings"> <div class="card-body"> <div class="card-text"> <form class="read-only short"> @@ -63,15 +60,38 @@ </div> </div> </div> + <div class="tab-pane" id="history" role="tabpanel" aria-labelledby="tab-history"> + <div class="card-body"> + <event-log v-if="loadEventLog" :object-id="domain.id" object-type="domain" ref="eventLog" class="card-text mb-0"></event-log> + </div> + </div> </div> + + <modal-dialog id="suspend-dialog" ref="suspendDialog" :title="$t(`btn.${suspendAction}`)" @click="submitSuspend()" :buttons="['submit']"> + <textarea v-model="comment" name="comment" class="form-control" :placeholder="$t('form.comment')" rows="3"></textarea> + </modal-dialog> </div> </template> <script> + import EventLog from '../Widgets/EventLog' + import ModalDialog from '../Widgets/ModalDialog' + export default { + components: { + EventLog, + ModalDialog + }, data() { return { - domain: null + comment: '', + domain: {}, + loadEventLog: false + } + }, + computed: { + suspendAction() { + return this.domain.isSuspended ? 'unsuspend' : 'suspend' } }, created() { @@ -83,22 +103,29 @@ }) .catch(this.$root.errorHandler) }, + mounted() { + this.$refs.tabs.clickHandler('history', () => { this.loadEventLog = true }) + }, methods: { - suspendDomain() { - axios.post('/api/v4/domains/' + this.domain.id + '/suspend') - .then(response => { - if (response.data.status == 'success') { - this.$toast.success(response.data.message) - this.domain = Object.assign({}, this.domain, { isSuspended: true }) - } - }) + setSuspendState() { + this.$root.clearFormValidation($('#suspend-dialog')) + this.$refs.suspendDialog.show() }, - unsuspendDomain() { - axios.post('/api/v4/domains/' + this.domain.id + '/unsuspend') + submitSuspend() { + const post = { comment: this.comment } + + axios.post(`/api/v4/domains/${this.domain.id}/${this.suspendAction}`, post) .then(response => { if (response.data.status == 'success') { this.$toast.success(response.data.message) - this.domain = Object.assign({}, this.domain, { isSuspended: false }) + this.domain = Object.assign({}, this.domain, { isSuspended: !this.domain.isSuspended }) + + this.$refs.suspendDialog.hide() + this.comment = '' + + if (this.loadEventLog) { + this.$refs.eventLog.loadLog({ reset: true }) + } } }) } 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 @@ -77,8 +77,8 @@ </div> </form> <div class="mt-2 buttons"> - <btn :id="'button-' + (user.isSuspended ? 'unsuspend' : 'suspend')" class="btn-outline-primary" @click="setSuspendState"> - {{ $t(user.isSuspended ? 'btn.unsuspend' : 'btn.suspend') }} + <btn :id="`button-${suspendAction}`" class="btn-outline-primary" @click="setSuspendState"> + {{ $t(`btn.${suspendAction}`) }} </btn> <btn id="button-resync" class="btn-outline-primary" @click="resyncUser"> {{ $t('btn.resync') }} @@ -228,21 +228,26 @@ </div> </div> </div> + <div class="tab-pane" id="history" role="tabpanel" aria-labelledby="tab-history"> + <div class="card-body"> + <event-log v-if="loadEventLog" :object-id="user.id" object-type="user" ref="eventLog" class="card-text mb-0"></event-log> + </div> + </div> </div> <modal-dialog id="discount-dialog" ref="discountDialog" :title="$t('user.discount-title')" @click="submitDiscount()" :buttons="['submit']"> - <div> - <select v-model="wallet.discount_id" class="form-select"> - <option value="">- {{ $t('form.none') }} -</option> - <option v-for="item in discounts" :value="item.id" :key="item.id">{{ item.label }}</option> - </select> - </div> + <select v-model="wallet.discount_id" class="form-select"> + <option value="">- {{ $t('form.none') }} -</option> + <option v-for="item in discounts" :value="item.id" :key="item.id">{{ item.label }}</option> + </select> </modal-dialog> <modal-dialog id="email-dialog" ref="emailDialog" :title="$t('user.ext-email')" @click="submitEmail()" :buttons="['submit']"> - <div> - <input v-model="external_email" name="external_email" class="form-control"> - </div> + <input v-model="external_email" name="external_email" class="form-control"> + </modal-dialog> + + <modal-dialog id="suspend-dialog" ref="suspendDialog" :title="$t(`btn.${suspendAction}`)" @click="submitSuspend()" :buttons="['submit']"> + <textarea v-model="comment" name="comment" class="form-control" :placeholder="$t('form.comment')" rows="3"></textarea> </modal-dialog> <modal-dialog id="oneoff-dialog" ref="oneoffDialog" @click="submitOneOff()" :buttons="['submit']" @@ -273,6 +278,7 @@ </template> <script> + import EventLog from '../Widgets/EventLog' import ModalDialog from '../Widgets/ModalDialog' import TransactionLog from '../Widgets/TransactionLog' import { ListTable } from '../Widgets/ListTools' @@ -295,6 +301,7 @@ components: { DistlistList, DomainList, + EventLog, ListTable, ModalDialog, ResourceList, @@ -320,9 +327,7 @@ ], footLabel: 'user.aliases-none' }, - oneoff_amount: '', - oneoff_description: '', - oneoff_negative: false, + comment: '', discount: 0, discount_description: '', discounts: [], @@ -330,6 +335,10 @@ folders: [], has2FA: false, hasBeta: false, + loadEventLog: false, + oneoff_amount: '', + oneoff_description: '', + oneoff_negative: false, wallet: {}, walletReload: false, distlists: [], @@ -361,7 +370,8 @@ { label: 'user.distlists', count: 0 }, { label: 'user.resources', count: 0 }, { label: 'dashboard.shared-folders', count: 0 }, - { label: 'form.settings' } + { label: 'form.settings' }, + { label: 'log.history' } ], users: [], user: { @@ -372,6 +382,11 @@ } } }, + computed: { + suspendAction() { + return this.user.isSuspended ? 'unsuspend' : 'suspend' + } + }, created() { const user_id = this.$route.params.user @@ -476,6 +491,7 @@ .catch(this.$root.errorHandler) }, mounted() { + this.$refs.tabs.clickHandler('history', () => { this.loadEventLog = true }) this.$refs.discountDialog.events({ shown: () => { // Note: Vue v-model is strict, convert null to a string @@ -633,11 +649,23 @@ }) }, setSuspendState() { - axios.post('/api/v4/users/' + this.user.id + '/' + (this.user.isSuspended ? 'unsuspend' : 'suspend')) + this.$root.clearFormValidation($('#suspend-dialog')) + this.$refs.suspendDialog.show() + }, + submitSuspend() { + const post = { comment: this.comment } + + axios.post(`/api/v4/users/${this.user.id}/${this.suspendAction}`, post) .then(response => { if (response.data.status == 'success') { this.$toast.success(response.data.message) this.user = Object.assign({}, this.user, { isSuspended: !this.user.isSuspended }) + this.$refs.suspendDialog.hide() + this.comment = '' + + if (this.loadEventLog) { + this.$refs.eventLog.loadLog({ reset: true }) + } } }) } diff --git a/src/resources/vue/Widgets/EventLog.vue b/src/resources/vue/Widgets/EventLog.vue new file mode 100644 --- /dev/null +++ b/src/resources/vue/Widgets/EventLog.vue @@ -0,0 +1,69 @@ +<template> + <div> + <table class="table table-sm m-0 eventlog"> + <thead> + <tr> + <th scope="col">{{ $t('form.date') }}</th> + <th scope="col">{{ $t('log.event') }}</th> + <th scope="col">{{ $t('form.comment') }}</th> + </tr> + </thead> + <tbody> + <tr v-for="event in log" :id="'log' + event.id" :key="event.id"> + <td class="datetime">{{ event.createdAt }}</td> + <td>{{ event.event }}</td> + <td class="description"> + <btn v-if="event.data || event.user" class="btn-link btn-action btn-more" icon="angle-right" :title="$t('form.more')" @click="loadDetails"></btn> + <btn v-if="event.data || event.user" class="btn-link btn-action btn-less" icon="angle-down" :title="$t('form.less')" @click="hideDetails"></btn> + {{ event.comment }} + <pre v-if="event.data" class="details text-monospace p-1 m-1 ms-3">{{ JSON.stringify(event.data, null, 2) }}</pre> + <div v-if="event.user" class="details email text-nowrap text-secondary ms-3"> + <svg-icon icon="user" class="me-1"></svg-icon>{{ event.user }} + </div> + </td> + </tr> + </tbody> + <list-foot :text="$t('log.list-none')" :colspan="3"></list-foot> + </table> + <list-more v-if="hasMore" :on-click="loadLog"></list-more> + </div> +</template> + +<script> + import ListTools from './ListTools' + import { library } from '@fortawesome/fontawesome-svg-core' + + library.add( + require('@fortawesome/free-solid-svg-icons/faAngleDown').definition, + require('@fortawesome/free-solid-svg-icons/faAngleRight').definition + ) + + export default { + mixins: [ ListTools ], + props: { + objectId: { type: [ String, Number ], default: null }, + objectType: { type: String, default: null }, + }, + data() { + return { + log: [] + } + }, + mounted() { + this.loadLog({ reset: true }) + }, + methods: { + loadDetails(event) { + $(event.target).closest('tr').addClass('open') + }, + hideDetails(event) { + $(event.target).closest('tr').removeClass('open') + }, + loadLog(params) { + if (this.objectId && this.objectType) { + this.listSearch('log', `/api/v4/eventlog/${this.objectType}/${this.objectId}`, params) + } + } + } + } +</script> diff --git a/src/resources/vue/Widgets/ListTools.vue b/src/resources/vue/Widgets/ListTools.vue --- a/src/resources/vue/Widgets/ListTools.vue +++ b/src/resources/vue/Widgets/ListTools.vue @@ -125,7 +125,6 @@ get.search = this.currentSearch } - if ('parent' in params) { get.parent = params.parent this.currentParent = params.parent diff --git a/src/resources/vue/Widgets/ModalDialog.vue b/src/resources/vue/Widgets/ModalDialog.vue --- a/src/resources/vue/Widgets/ModalDialog.vue +++ b/src/resources/vue/Widgets/ModalDialog.vue @@ -62,7 +62,7 @@ if (this.cancelFocus) { $(event.target).find('button.modal-cancel').focus() } else { - $(event.target).find('input,select').first().focus() + $(event.target).find('input,select,textarea').first().focus() } }) diff --git a/src/routes/api.php b/src/routes/api.php --- a/src/routes/api.php +++ b/src/routes/api.php @@ -235,6 +235,8 @@ Route::post('domains/{id}/suspend', [API\V4\Admin\DomainsController::class, 'suspend']); Route::post('domains/{id}/unsuspend', [API\V4\Admin\DomainsController::class, 'unsuspend']); + Route::get('eventlog/{type}/{id}', [API\V4\Admin\EventLogController::class, 'index']); + Route::apiResource('groups', API\V4\Admin\GroupsController::class); Route::post('groups/{id}/suspend', [API\V4\Admin\GroupsController::class, 'suspend']); Route::post('groups/{id}/unsuspend', [API\V4\Admin\GroupsController::class, 'unsuspend']); @@ -275,6 +277,8 @@ Route::post('domains/{id}/suspend', [API\V4\Reseller\DomainsController::class, 'suspend']); Route::post('domains/{id}/unsuspend', [API\V4\Reseller\DomainsController::class, 'unsuspend']); + Route::get('eventlog/{type}/{id}', [API\V4\Reseller\EventLogController::class, 'index']); + Route::apiResource('groups', API\V4\Reseller\GroupsController::class); Route::post('groups/{id}/suspend', [API\V4\Reseller\GroupsController::class, 'suspend']); Route::post('groups/{id}/unsuspend', [API\V4\Reseller\GroupsController::class, 'unsuspend']); diff --git a/src/tests/Browser/Admin/DistlistTest.php b/src/tests/Browser/Admin/DistlistTest.php --- a/src/tests/Browser/Admin/DistlistTest.php +++ b/src/tests/Browser/Admin/DistlistTest.php @@ -2,9 +2,11 @@ namespace Tests\Browser\Admin; +use App\EventLog; use App\Group; use Illuminate\Support\Facades\Queue; use Tests\Browser; +use Tests\Browser\Components\Dialog; use Tests\Browser\Components\Toast; use Tests\Browser\Pages\Admin\Distlist as DistlistPage; use Tests\Browser\Pages\Admin\User as UserPage; @@ -23,6 +25,7 @@ self::useAdminUrl(); $this->deleteTestGroup('group-test@kolab.org'); + Eventlog::query()->delete(); } /** @@ -31,6 +34,7 @@ public function tearDown(): void { $this->deleteTestGroup('group-test@kolab.org'); + Eventlog::query()->delete(); parent::tearDown(); } @@ -65,6 +69,12 @@ $group->save(); $group->setConfig(['sender_policy' => ['test1.com', 'test2.com']]); + $event1 = EventLog::createFor($group, EventLog::TYPE_SUSPENDED, 'Event 1'); + $event2 = EventLog::createFor($group, EventLog::TYPE_UNSUSPENDED, 'Event 2', ['test' => 'test-data']); + $event2->refresh(); + $event1->created_at = (clone $event2->created_at)->subDay(); + $event1->save(); + $distlist_page = new DistlistPage($group->id); $user_page = new UserPage($user->id); @@ -75,7 +85,7 @@ ->visit($user_page) ->on($user_page) ->click('@nav #tab-distlists') - ->pause(1000) + ->waitFor('@user-distlists table tbody') ->click('@user-distlists table tbody tr:first-child td a') ->on($distlist_page) ->assertSeeIn('@distlist-info .card-title', $group->email) @@ -91,14 +101,42 @@ ->assertSeeIn('.row:nth-child(4) #members', $group->members[0]) ->assertSeeIn('.row:nth-child(4) #members', $group->members[1]); }) - ->assertElementsCount('ul.nav-tabs', 1) - ->assertSeeIn('ul.nav-tabs .nav-link', 'Settings') + ->assertElementsCount('ul.nav-tabs li', 2) + ->assertSeeIn('ul.nav-tabs #tab-settings', 'Settings') ->with('@distlist-settings form', function (Browser $browser) { $browser->assertElementsCount('.row', 1) ->assertSeeIn('.row:nth-child(1) label', 'Sender Access List') ->assertSeeIn('.row:nth-child(1) #sender_policy', 'test1.com, test2.com'); }); + // Assert History tab + $browser->assertSeeIn('ul.nav-tabs #tab-history', 'History') + ->click('ul.nav-tabs #tab-history') + ->whenAvailable('@distlist-history table', function (Browser $browser) use ($event1, $event2) { + $browser->waitFor('tbody tr')->assertElementsCount('tbody tr', 2) + // row 1 + ->assertSeeIn('tr:nth-child(1) td:nth-child(1)', $event2->created_at->toDateTimeString()) + ->assertSeeIn('tr:nth-child(1) td:nth-child(2)', 'Unsuspended') + ->assertSeeIn('tr:nth-child(1) td:nth-child(3)', $event2->comment) + ->assertMissing('tr:nth-child(1) td:nth-child(3) div') + ->assertMissing('tr:nth-child(1) td:nth-child(3) pre') + ->assertMissing('tr:nth-child(1) td:nth-child(3) .btn-less') + ->click('tr:nth-child(1) td:nth-child(3) .btn-more') + ->assertMissing('tr:nth-child(1) td:nth-child(3) div.email') + ->assertSeeIn('tr:nth-child(1) td:nth-child(3) pre', 'test-data') + ->assertMissing('tr:nth-child(1) td:nth-child(3) .btn-more') + ->click('tr:nth-child(1) td:nth-child(3) .btn-less') + ->assertMissing('tr:nth-child(1) td:nth-child(3) div') + ->assertMissing('tr:nth-child(1) td:nth-child(3) pre') + ->assertMissing('tr:nth-child(1) td:nth-child(3) .btn-less') + // row 2 + ->assertSeeIn('tr:nth-child(2) td:nth-child(1)', $event1->created_at->toDateTimeString()) + ->assertSeeIn('tr:nth-child(2) td:nth-child(2)', 'Suspended') + ->assertSeeIn('tr:nth-child(2) td:nth-child(3)', $event1->comment) + ->assertMissing('tr:nth-child(2) td:nth-child(3) .btn-more') + ->assertMissing('tr:nth-child(2) td:nth-child(3) .btn-less'); + }); + // Test invalid group identifier $browser->visit('/distlist/abc')->assertErrorPage(404); }); @@ -112,6 +150,7 @@ public function testSuspendAndUnsuspend(): void { Queue::fake(); + Eventlog::query()->delete(); $this->browse(function (Browser $browser) { $user = $this->getTestUser('john@kolab.org'); @@ -125,14 +164,36 @@ ->assertMissing('@distlist-info #button-unsuspend') ->assertSeeIn('@distlist-info #status.text-success', 'Active') ->click('@distlist-info #button-suspend') + ->with(new Dialog('#suspend-dialog'), function (Browser $browser) { + $browser->assertSeeIn('@title', 'Suspend') + ->assertSeeIn('@button-cancel', 'Cancel') + ->assertSeeIn('@button-action', 'Submit') + ->type('textarea', 'test suspend') + ->click('@button-action'); + }) ->assertToast(Toast::TYPE_SUCCESS, 'Distribution list suspended successfully.') ->assertSeeIn('@distlist-info #status.text-warning', 'Suspended') - ->assertMissing('@distlist-info #button-suspend') - ->click('@distlist-info #button-unsuspend') + ->assertMissing('@distlist-info #button-suspend'); + + $event = EventLog::where('type', EventLog::TYPE_SUSPENDED)->first(); + $this->assertSame('test suspend', $event->comment); + $this->assertEquals($group->id, $event->object_id); + + $browser->click('@distlist-info #button-unsuspend') + ->with(new Dialog('#suspend-dialog'), function (Browser $browser) { + $browser->assertSeeIn('@title', 'Unsuspend') + ->assertSeeIn('@button-cancel', 'Cancel') + ->assertSeeIn('@button-action', 'Submit') + ->click('@button-action'); + }) ->assertToast(Toast::TYPE_SUCCESS, 'Distribution list unsuspended successfully.') ->assertSeeIn('@distlist-info #status.text-success', 'Active') ->assertVisible('@distlist-info #button-suspend') ->assertMissing('@distlist-info #button-unsuspend'); + + $event = EventLog::where('type', EventLog::TYPE_UNSUSPENDED)->first(); + $this->assertSame(null, $event->comment); + $this->assertEquals($group->id, $event->object_id); }); } } diff --git a/src/tests/Browser/Admin/DomainTest.php b/src/tests/Browser/Admin/DomainTest.php --- a/src/tests/Browser/Admin/DomainTest.php +++ b/src/tests/Browser/Admin/DomainTest.php @@ -3,7 +3,9 @@ namespace Tests\Browser\Admin; use App\Domain; +use App\EventLog; use Tests\Browser; +use Tests\Browser\Components\Dialog; use Tests\Browser\Components\Toast; use Tests\Browser\Pages\Admin\Domain as DomainPage; use Tests\Browser\Pages\Admin\User as UserPage; @@ -24,6 +26,8 @@ $this->deleteTestUser('test1@domainscontroller.com'); $this->deleteTestDomain('domainscontroller.com'); + Eventlog::query()->delete(); + self::useAdminUrl(); } @@ -38,6 +42,8 @@ $this->deleteTestUser('test1@domainscontroller.com'); $this->deleteTestDomain('domainscontroller.com'); + Eventlog::query()->delete(); + parent::tearDown(); } @@ -66,6 +72,12 @@ $domain->setSetting('spf_whitelist', null); + $event1 = EventLog::createFor($domain, EventLog::TYPE_SUSPENDED, 'Event 1'); + $event2 = EventLog::createFor($domain, EventLog::TYPE_UNSUSPENDED, 'Event 2', ['test' => 'test-data']); + $event2->refresh(); + $event1->created_at = (clone $event2->created_at)->subDay(); + $event1->save(); + // Goto the domain page $browser->visit(new Home()) ->submitLogon('jeroen@jeroen.jeroen', \App\Utils::generatePassphrase(), true) @@ -73,7 +85,7 @@ ->visit($user_page) ->on($user_page) ->click('@nav #tab-domains') - ->pause(1000) + ->waitFor('@user-domains table tbody') ->click('@user-domains table tbody tr:first-child td a'); $browser->on($domain_page) @@ -88,7 +100,7 @@ // Some tabs are loaded in background, wait a second $browser->pause(500) - ->assertElementsCount('@nav a', 2); + ->assertElementsCount('@nav a', 3); // Assert Configuration tab $browser->assertSeeIn('@nav #tab-config', 'Configuration') @@ -110,11 +122,40 @@ $domain->setSetting('spf_whitelist', json_encode(['.test1.com', '.test2.com'])); $browser->refresh() + ->on($domain_page) ->waitFor('@nav #tab-settings') ->click('@nav #tab-settings') - ->with('@domain-settings form', function (Browser $browser) { + ->whenAvailable('@domain-settings form', function (Browser $browser) { $browser->assertSeeIn('.row:first-child .form-control-plaintext', '.test1.com, .test2.com'); }); + + // Assert History tab + $browser->assertSeeIn('@nav #tab-history', 'History') + ->click('@nav #tab-history') + ->whenAvailable('@domain-history table', function (Browser $browser) use ($event1, $event2) { + $browser->waitFor('tbody tr')->assertElementsCount('tbody tr', 2) + // row 1 + ->assertSeeIn('tr:nth-child(1) td:nth-child(1)', $event2->created_at->toDateTimeString()) + ->assertSeeIn('tr:nth-child(1) td:nth-child(2)', 'Unsuspended') + ->assertSeeIn('tr:nth-child(1) td:nth-child(3)', $event2->comment) + ->assertMissing('tr:nth-child(1) td:nth-child(3) div') + ->assertMissing('tr:nth-child(1) td:nth-child(3) pre') + ->assertMissing('tr:nth-child(1) td:nth-child(3) .btn-less') + ->click('tr:nth-child(1) td:nth-child(3) .btn-more') + ->assertMissing('tr:nth-child(1) td:nth-child(3) div.email') + ->assertSeeIn('tr:nth-child(1) td:nth-child(3) pre', 'test-data') + ->assertMissing('tr:nth-child(1) td:nth-child(3) .btn-more') + ->click('tr:nth-child(1) td:nth-child(3) .btn-less') + ->assertMissing('tr:nth-child(1) td:nth-child(3) div') + ->assertMissing('tr:nth-child(1) td:nth-child(3) pre') + ->assertMissing('tr:nth-child(1) td:nth-child(3) .btn-less') + // row 2 + ->assertSeeIn('tr:nth-child(2) td:nth-child(1)', $event1->created_at->toDateTimeString()) + ->assertSeeIn('tr:nth-child(2) td:nth-child(2)', 'Suspended') + ->assertSeeIn('tr:nth-child(2) td:nth-child(3)', $event1->comment) + ->assertMissing('tr:nth-child(2) td:nth-child(3) .btn-more') + ->assertMissing('tr:nth-child(2) td:nth-child(3) .btn-less'); + }); }); } @@ -125,6 +166,8 @@ */ public function testSuspendAndUnsuspend(): void { + EventLog::query()->delete(); + $this->browse(function (Browser $browser) { $sku_domain = \App\Sku::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $user = $this->getTestUser('test1@domainscontroller.com'); @@ -146,14 +189,36 @@ ->assertVisible('@domain-info #button-suspend') ->assertMissing('@domain-info #button-unsuspend') ->click('@domain-info #button-suspend') + ->with(new Dialog('#suspend-dialog'), function (Browser $browser) { + $browser->assertSeeIn('@title', 'Suspend') + ->assertSeeIn('@button-cancel', 'Cancel') + ->assertSeeIn('@button-action', 'Submit') + ->type('textarea', 'test suspend') + ->click('@button-action'); + }) ->assertToast(Toast::TYPE_SUCCESS, 'Domain suspended successfully.') ->assertSeeIn('@domain-info #status span.text-warning', 'Suspended') - ->assertMissing('@domain-info #button-suspend') - ->click('@domain-info #button-unsuspend') + ->assertMissing('@domain-info #button-suspend'); + + $event = EventLog::where('type', EventLog::TYPE_SUSPENDED)->first(); + $this->assertSame('test suspend', $event->comment); + $this->assertEquals($domain->id, $event->object_id); + + $browser->click('@domain-info #button-unsuspend') + ->with(new Dialog('#suspend-dialog'), function (Browser $browser) { + $browser->assertSeeIn('@title', 'Unsuspend') + ->assertSeeIn('@button-cancel', 'Cancel') + ->assertSeeIn('@button-action', 'Submit') + ->click('@button-action'); + }) ->assertToast(Toast::TYPE_SUCCESS, 'Domain unsuspended successfully.') ->assertSeeIn('@domain-info #status span.text-success', 'Active') ->assertVisible('@domain-info #button-suspend') ->assertMissing('@domain-info #button-unsuspend'); + + $event = EventLog::where('type', EventLog::TYPE_UNSUSPENDED)->first(); + $this->assertSame(null, $event->comment); + $this->assertEquals($domain->id, $event->object_id); }); } } 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 @@ -5,6 +5,7 @@ use App\Auth\SecondFactor; use App\Discount; use App\Entitlement; +use App\EventLog; use App\Sku; use App\User; use Tests\Browser; @@ -39,6 +40,8 @@ $wallet->save(); Entitlement::where('cost', '>=', 5000)->delete(); + Eventlog::query()->delete(); + $this->deleteTestGroup('group-test@kolab.org'); $this->deleteTestUser('userstest1@kolabnow.com'); } @@ -61,6 +64,8 @@ $wallet->save(); Entitlement::where('cost', '>=', 5000)->delete(); + Eventlog::query()->delete(); + $this->deleteTestGroup('group-test@kolab.org'); $this->deleteTestUser('userstest1@kolabnow.com'); @@ -89,6 +94,15 @@ $jack->setSetting('limit_geo', null); $jack->setSetting('guam_enabled', null); + $event1 = EventLog::createFor($jack, EventLog::TYPE_SUSPENDED, 'Event 1'); + $event2 = EventLog::createFor($jack, EventLog::TYPE_UNSUSPENDED, 'Event 2', ['test' => 'test-data']); + $event2->refresh(); + $event1->created_at = (clone $event2->created_at)->subDay(); + $event1->user_email = 'jeroen@jeroen.jeroen'; + $event1->save(); + $event2->user_email = 'test@test.com'; + $event2->save(); + $page = new UserPage($jack->id); $browser->visit(new Home()) @@ -120,7 +134,7 @@ // Some tabs are loaded in background, wait a second $browser->pause(500) - ->assertElementsCount('@nav a', 9); + ->assertElementsCount('@nav a', 10); // Note: Finances tab is tested in UserFinancesTest.php $browser->assertSeeIn('@nav #tab-finances', 'Finances'); @@ -202,6 +216,35 @@ ->assertSeeIn('.row:nth-child(3) #limit_geo', 'No restrictions') ->assertMissing('#limit_geo + button'); }); + + // Assert History tab + $browser->assertSeeIn('@nav #tab-history', 'History') + ->click('@nav #tab-history') + ->whenAvailable('@user-history table', function (Browser $browser) use ($event1, $event2) { + $browser->waitFor('tbody tr')->assertElementsCount('tbody tr', 2) + // row 1 + ->assertSeeIn('tr:nth-child(1) td:nth-child(1)', $event2->created_at->toDateTimeString()) + ->assertSeeIn('tr:nth-child(1) td:nth-child(2)', 'Unsuspended') + ->assertSeeIn('tr:nth-child(1) td:nth-child(3)', $event2->comment) + ->assertMissing('tr:nth-child(1) td:nth-child(3) div') + ->assertMissing('tr:nth-child(1) td:nth-child(3) pre') + ->assertMissing('tr:nth-child(1) td:nth-child(3) .btn-less') + ->click('tr:nth-child(1) td:nth-child(3) .btn-more') + ->assertSeeIn('tr:nth-child(1) td:nth-child(3) div.email', $event2->user_email) + ->assertSeeIn('tr:nth-child(1) td:nth-child(3) pre', 'test-data') + ->assertMissing('tr:nth-child(1) td:nth-child(3) .btn-more') + ->click('tr:nth-child(1) td:nth-child(3) .btn-less') + ->assertMissing('tr:nth-child(1) td:nth-child(3) div') + ->assertMissing('tr:nth-child(1) td:nth-child(3) pre') + ->assertMissing('tr:nth-child(1) td:nth-child(3) .btn-less') + // row 2 + ->assertSeeIn('tr:nth-child(2) td:nth-child(1)', $event1->created_at->toDateTimeString()) + ->assertSeeIn('tr:nth-child(2) td:nth-child(2)', 'Suspended') + ->assertSeeIn('tr:nth-child(2) td:nth-child(3)', $event1->comment) + ->click('tr:nth-child(2) td:nth-child(3) .btn-more') + ->assertSeeIn('tr:nth-child(2) td:nth-child(3) div.email', $event1->user_email) + ->assertMissing('tr:nth-child(2) td:nth-child(3) pre'); + }); }); } @@ -257,7 +300,7 @@ // Some tabs are loaded in background, wait a second $browser->pause(500) - ->assertElementsCount('@nav a', 9); + ->assertElementsCount('@nav a', 10); // Note: Finances tab is tested in UserFinancesTest.php $browser->assertSeeIn('@nav #tab-finances', 'Finances'); @@ -348,6 +391,14 @@ ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', 'folder-contact@kolab.org') ->assertMissing('table tfoot'); }); + + // Assert History tab + $browser->assertSeeIn('@nav #tab-history', 'History') + ->click('@nav #tab-history') + ->whenAvailable('@user-history table', function (Browser $browser) { + $browser->assertElementsCount('tbody tr', 0) + ->assertSeeIn('tfoot tr td', "There's no events in the log"); + }); }); // Now we go to Ned's info page, he's a controller on John's wallet @@ -389,7 +440,7 @@ // Some tabs are loaded in background, wait a second $browser->pause(500) - ->assertElementsCount('@nav a', 9); + ->assertElementsCount('@nav a', 10); // Note: Finances tab is tested in UserFinancesTest.php $browser->assertSeeIn('@nav #tab-finances', 'Finances'); @@ -543,14 +594,34 @@ ->assertVisible('@user-info #button-suspend') ->assertMissing('@user-info #button-unsuspend') ->click('@user-info #button-suspend') + ->with(new Dialog('#suspend-dialog'), function (Browser $browser) { + $browser->assertSeeIn('@title', 'Suspend') + ->assertSeeIn('@button-cancel', 'Cancel') + ->assertSeeIn('@button-action', 'Submit') + ->type('textarea', 'test suspend') + ->click('@button-action'); + }) ->assertToast(Toast::TYPE_SUCCESS, 'User suspended successfully.') ->assertSeeIn('@user-info #status span.text-warning', 'Suspended') - ->assertMissing('@user-info #button-suspend') - ->click('@user-info #button-unsuspend') + ->assertMissing('@user-info #button-suspend'); + + $event = EventLog::where('type', EventLog::TYPE_SUSPENDED)->first(); + $this->assertSame('test suspend', $event->comment); + + $browser->click('@user-info #button-unsuspend') + ->with(new Dialog('#suspend-dialog'), function (Browser $browser) { + $browser->assertSeeIn('@title', 'Unsuspend') + ->assertSeeIn('@button-cancel', 'Cancel') + ->assertSeeIn('@button-action', 'Submit') + ->click('@button-action'); + }) ->assertToast(Toast::TYPE_SUCCESS, 'User unsuspended successfully.') ->assertSeeIn('@user-info #status span.text-success', 'Active') ->assertVisible('@user-info #button-suspend') ->assertMissing('@user-info #button-unsuspend'); + + $event = EventLog::where('type', EventLog::TYPE_UNSUSPENDED)->first(); + $this->assertSame(null, $event->comment); }); } diff --git a/src/tests/Browser/Pages/Admin/Distlist.php b/src/tests/Browser/Pages/Admin/Distlist.php --- a/src/tests/Browser/Pages/Admin/Distlist.php +++ b/src/tests/Browser/Pages/Admin/Distlist.php @@ -38,6 +38,7 @@ public function assert($browser): void { $browser->waitForLocation($this->url()) + ->waitUntilMissing('@app .app-loader') ->waitFor('@distlist-info'); } @@ -52,6 +53,7 @@ '@app' => '#app', '@distlist-info' => '#distlist-info', '@distlist-settings' => '#settings', + '@distlist-history' => '#history', ]; } } diff --git a/src/tests/Browser/Pages/Admin/Domain.php b/src/tests/Browser/Pages/Admin/Domain.php --- a/src/tests/Browser/Pages/Admin/Domain.php +++ b/src/tests/Browser/Pages/Admin/Domain.php @@ -38,6 +38,7 @@ public function assert($browser): void { $browser->waitForLocation($this->url()) + ->waitUntilMissing('@app .app-loader') ->waitFor('@domain-info'); } @@ -54,6 +55,7 @@ '@nav' => 'ul.nav-tabs', '@domain-config' => '#config', '@domain-settings' => '#settings', + '@domain-history' => '#history', ]; } } diff --git a/src/tests/Browser/Pages/Admin/Resource.php b/src/tests/Browser/Pages/Admin/Resource.php --- a/src/tests/Browser/Pages/Admin/Resource.php +++ b/src/tests/Browser/Pages/Admin/Resource.php @@ -38,6 +38,7 @@ public function assert($browser): void { $browser->waitForLocation($this->url()) + ->waitUntilMissing('@app .app-loader') ->waitFor('@resource-info'); } diff --git a/src/tests/Browser/Pages/Admin/SharedFolder.php b/src/tests/Browser/Pages/Admin/SharedFolder.php --- a/src/tests/Browser/Pages/Admin/SharedFolder.php +++ b/src/tests/Browser/Pages/Admin/SharedFolder.php @@ -38,6 +38,7 @@ public function assert($browser): void { $browser->waitForLocation($this->url()) + ->waitUntilMissing('@app .app-loader') ->waitFor('@folder-info'); } diff --git a/src/tests/Browser/Pages/Admin/User.php b/src/tests/Browser/Pages/Admin/User.php --- a/src/tests/Browser/Pages/Admin/User.php +++ b/src/tests/Browser/Pages/Admin/User.php @@ -58,6 +58,7 @@ '@user-subscriptions' => '#subscriptions', '@user-distlists' => '#distlists', '@user-domains' => '#domains', + '@user-history' => '#history', '@user-resources' => '#resources', '@user-folders' => '#folders', '@user-users' => '#users', diff --git a/src/tests/Browser/Reseller/DistlistTest.php b/src/tests/Browser/Reseller/DistlistTest.php --- a/src/tests/Browser/Reseller/DistlistTest.php +++ b/src/tests/Browser/Reseller/DistlistTest.php @@ -2,9 +2,11 @@ namespace Tests\Browser\Reseller; +use App\EventLog; use App\Group; use Illuminate\Support\Facades\Queue; use Tests\Browser; +use Tests\Browser\Components\Dialog; use Tests\Browser\Components\Toast; use Tests\Browser\Pages\Admin\Distlist as DistlistPage; use Tests\Browser\Pages\Admin\User as UserPage; @@ -23,6 +25,7 @@ self::useResellerUrl(); $this->deleteTestGroup('group-test@kolab.org'); + Eventlog::query()->delete(); } /** @@ -31,6 +34,7 @@ public function tearDown(): void { $this->deleteTestGroup('group-test@kolab.org'); + Eventlog::query()->delete(); parent::tearDown(); } @@ -75,7 +79,7 @@ ->visit($user_page) ->on($user_page) ->click('@nav #tab-distlists') - ->pause(1000) + ->waitFor('@user-distlists table tbody') ->click('@user-distlists table tbody tr:first-child td a') ->on($distlist_page) ->assertSeeIn('@distlist-info .card-title', $group->email) @@ -91,13 +95,14 @@ ->assertSeeIn('.row:nth-child(4) #members', $group->members[0]) ->assertSeeIn('.row:nth-child(4) #members', $group->members[1]); }) - ->assertElementsCount('ul.nav-tabs', 1) - ->assertSeeIn('ul.nav-tabs .nav-link', 'Settings') + ->assertElementsCount('ul.nav-tabs li', 2) + ->assertSeeIn('ul.nav-tabs #tab-settings', 'Settings') ->with('@distlist-settings form', function (Browser $browser) { $browser->assertElementsCount('.row', 1) ->assertSeeIn('.row:nth-child(1) label', 'Sender Access List') ->assertSeeIn('.row:nth-child(1) #sender_policy', 'test1.com, test2.com'); - }); + }) + ->assertSeeIn('ul.nav-tabs #tab-history', 'History'); // Test invalid group identifier $browser->visit('/distlist/abc')->assertErrorPage(404); @@ -112,6 +117,7 @@ public function testSuspendAndUnsuspend(): void { Queue::fake(); + Eventlog::query()->delete(); $this->browse(function (Browser $browser) { $user = $this->getTestUser('john@kolab.org'); @@ -125,14 +131,36 @@ ->assertMissing('@distlist-info #button-unsuspend') ->assertSeeIn('@distlist-info #status.text-success', 'Active') ->click('@distlist-info #button-suspend') + ->with(new Dialog('#suspend-dialog'), function (Browser $browser) { + $browser->assertSeeIn('@title', 'Suspend') + ->assertSeeIn('@button-cancel', 'Cancel') + ->assertSeeIn('@button-action', 'Submit') + ->type('textarea', 'test suspend') + ->click('@button-action'); + }) ->assertToast(Toast::TYPE_SUCCESS, 'Distribution list suspended successfully.') ->assertSeeIn('@distlist-info #status.text-warning', 'Suspended') - ->assertMissing('@distlist-info #button-suspend') - ->click('@distlist-info #button-unsuspend') + ->assertMissing('@distlist-info #button-suspend'); + + $event = EventLog::where('type', EventLog::TYPE_SUSPENDED)->first(); + $this->assertSame('test suspend', $event->comment); + $this->assertEquals($group->id, $event->object_id); + + $browser->click('@distlist-info #button-unsuspend') + ->with(new Dialog('#suspend-dialog'), function (Browser $browser) { + $browser->assertSeeIn('@title', 'Unsuspend') + ->assertSeeIn('@button-cancel', 'Cancel') + ->assertSeeIn('@button-action', 'Submit') + ->click('@button-action'); + }) ->assertToast(Toast::TYPE_SUCCESS, 'Distribution list unsuspended successfully.') ->assertSeeIn('@distlist-info #status.text-success', 'Active') ->assertVisible('@distlist-info #button-suspend') ->assertMissing('@distlist-info #button-unsuspend'); + + $event = EventLog::where('type', EventLog::TYPE_UNSUSPENDED)->first(); + $this->assertSame(null, $event->comment); + $this->assertEquals($group->id, $event->object_id); }); } } diff --git a/src/tests/Browser/Reseller/DomainTest.php b/src/tests/Browser/Reseller/DomainTest.php --- a/src/tests/Browser/Reseller/DomainTest.php +++ b/src/tests/Browser/Reseller/DomainTest.php @@ -3,7 +3,9 @@ namespace Tests\Browser\Reseller; use App\Domain; +use App\EventLog; use Tests\Browser; +use Tests\Browser\Components\Dialog; use Tests\Browser\Components\Toast; use Tests\Browser\Pages\Admin\Domain as DomainPage; use Tests\Browser\Pages\Admin\User as UserPage; @@ -24,6 +26,8 @@ $this->deleteTestUser('test1@domainscontroller.com'); $this->deleteTestDomain('domainscontroller.com'); + Eventlog::query()->delete(); + self::useResellerUrl(); } @@ -35,6 +39,8 @@ $this->deleteTestUser('test1@domainscontroller.com'); $this->deleteTestDomain('domainscontroller.com'); + Eventlog::query()->delete(); + parent::tearDown(); } @@ -84,7 +90,9 @@ // Some tabs are loaded in background, wait a second $browser->pause(500) - ->assertElementsCount('@nav a', 2); + ->assertElementsCount('@nav a', 3) + ->assertSeeIn('@nav #tab-settings', 'Settings') + ->assertSeeIn('@nav #tab-history', 'History'); // Assert Configuration tab $browser->assertSeeIn('@nav #tab-config', 'Configuration') @@ -102,6 +110,8 @@ */ public function testSuspendAndUnsuspend(): void { + EventLog::query()->delete(); + $this->browse(function (Browser $browser) { $sku_domain = \App\Sku::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $user = $this->getTestUser('test1@domainscontroller.com'); @@ -123,14 +133,36 @@ ->assertVisible('@domain-info #button-suspend') ->assertMissing('@domain-info #button-unsuspend') ->click('@domain-info #button-suspend') + ->with(new Dialog('#suspend-dialog'), function (Browser $browser) { + $browser->assertSeeIn('@title', 'Suspend') + ->assertSeeIn('@button-cancel', 'Cancel') + ->assertSeeIn('@button-action', 'Submit') + ->type('textarea', 'test suspend') + ->click('@button-action'); + }) ->assertToast(Toast::TYPE_SUCCESS, 'Domain suspended successfully.') ->assertSeeIn('@domain-info #status span.text-warning', 'Suspended') - ->assertMissing('@domain-info #button-suspend') - ->click('@domain-info #button-unsuspend') + ->assertMissing('@domain-info #button-suspend'); + + $event = EventLog::where('type', EventLog::TYPE_SUSPENDED)->first(); + $this->assertSame('test suspend', $event->comment); + $this->assertEquals($domain->id, $event->object_id); + + $browser->click('@domain-info #button-unsuspend') + ->with(new Dialog('#suspend-dialog'), function (Browser $browser) { + $browser->assertSeeIn('@title', 'Unsuspend') + ->assertSeeIn('@button-cancel', 'Cancel') + ->assertSeeIn('@button-action', 'Submit') + ->click('@button-action'); + }) ->assertToast(Toast::TYPE_SUCCESS, 'Domain unsuspended successfully.') ->assertSeeIn('@domain-info #status span.text-success', 'Active') ->assertVisible('@domain-info #button-suspend') ->assertMissing('@domain-info #button-unsuspend'); + + $event = EventLog::where('type', EventLog::TYPE_UNSUSPENDED)->first(); + $this->assertSame(null, $event->comment); + $this->assertEquals($domain->id, $event->object_id); }); } } diff --git a/src/tests/Browser/Reseller/UserTest.php b/src/tests/Browser/Reseller/UserTest.php --- a/src/tests/Browser/Reseller/UserTest.php +++ b/src/tests/Browser/Reseller/UserTest.php @@ -4,6 +4,7 @@ use App\Auth\SecondFactor; use App\Discount; +use App\EventLog; use App\Sku; use App\User; use Tests\Browser; @@ -114,7 +115,7 @@ // Some tabs are loaded in background, wait a second $browser->pause(500) - ->assertElementsCount('@nav a', 9); + ->assertElementsCount('@nav a', 10); // Note: Finances tab is tested in UserFinancesTest.php $browser->assertSeeIn('@nav #tab-finances', 'Finances'); @@ -250,7 +251,7 @@ // Some tabs are loaded in background, wait a second $browser->pause(500) - ->assertElementsCount('@nav a', 9); + ->assertElementsCount('@nav a', 10); // Note: Finances tab is tested in UserFinancesTest.php $browser->assertSeeIn('@nav #tab-finances', 'Finances'); @@ -341,6 +342,9 @@ ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', 'folder-contact@kolab.org') ->assertMissing('table tfoot'); }); + + // Assert History tab + $browser->assertSeeIn('@nav #tab-history', 'History'); }); // Now we go to Ned's info page, he's a controller on John's wallet @@ -362,7 +366,7 @@ // Some tabs are loaded in background, wait a second $browser->pause(500) - ->assertElementsCount('@nav a', 9); + ->assertElementsCount('@nav a', 10); // Note: Finances tab is tested in UserFinancesTest.php $browser->assertSeeIn('@nav #tab-finances', 'Finances'); @@ -447,6 +451,9 @@ ->assertSeeIn('.row:nth-child(3) label', 'Geo-lockin') ->assertSeeIn('.row:nth-child(3) #limit_geo', 'No restrictions'); }); + + // Assert History tab + $browser->assertSeeIn('@nav #tab-history', 'History'); }); } @@ -510,6 +517,8 @@ */ public function testSuspendAndUnsuspend(): void { + EventLog::query()->delete(); + $this->browse(function (Browser $browser) { $john = $this->getTestUser('john@kolab.org'); @@ -517,14 +526,34 @@ ->assertVisible('@user-info #button-suspend') ->assertMissing('@user-info #button-unsuspend') ->click('@user-info #button-suspend') + ->with(new Dialog('#suspend-dialog'), function (Browser $browser) { + $browser->assertSeeIn('@title', 'Suspend') + ->assertSeeIn('@button-cancel', 'Cancel') + ->assertSeeIn('@button-action', 'Submit') + ->type('textarea', 'test suspend') + ->click('@button-action'); + }) ->assertToast(Toast::TYPE_SUCCESS, 'User suspended successfully.') ->assertSeeIn('@user-info #status span.text-warning', 'Suspended') - ->assertMissing('@user-info #button-suspend') - ->click('@user-info #button-unsuspend') + ->assertMissing('@user-info #button-suspend'); + + $event = EventLog::where('type', EventLog::TYPE_SUSPENDED)->first(); + $this->assertSame('test suspend', $event->comment); + + $browser->click('@user-info #button-unsuspend') + ->with(new Dialog('#suspend-dialog'), function (Browser $browser) { + $browser->assertSeeIn('@title', 'Unsuspend') + ->assertSeeIn('@button-cancel', 'Cancel') + ->assertSeeIn('@button-action', 'Submit') + ->click('@button-action'); + }) ->assertToast(Toast::TYPE_SUCCESS, 'User unsuspended successfully.') ->assertSeeIn('@user-info #status span.text-success', 'Active') ->assertVisible('@user-info #button-suspend') ->assertMissing('@user-info #button-unsuspend'); + + $event = EventLog::where('type', EventLog::TYPE_UNSUSPENDED)->first(); + $this->assertSame(null, $event->comment); }); } diff --git a/src/tests/Feature/Console/Group/SuspendTest.php b/src/tests/Feature/Console/Group/SuspendTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Console/Group/SuspendTest.php @@ -0,0 +1,75 @@ +<?php + +namespace Tests\Feature\Console\Group; + +use App\EventLog; +use App\Group; +use Illuminate\Support\Facades\Queue; +use Tests\TestCase; + +class SuspendTest extends TestCase +{ + /** + * {@inheritDoc} + */ + public function setUp(): void + { + parent::setUp(); + + $this->deleteTestGroup('group-test@kolabnow.com'); + EventLog::truncate(); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestGroup('group-test@kolabnow.com'); + EventLog::truncate(); + + parent::tearDown(); + } + + /** + * Test the command + */ + public function testHandle(): void + { + Queue::fake(); + + // Non-existing user + $code = \Artisan::call("group:suspend unknown"); + $output = trim(\Artisan::output()); + + $this->assertSame(1, $code); + $this->assertSame("Group not found.", $output); + + $group = $this->getTestGroup('group-test@kolabnow.com'); + + // Test success (no --comment) + $code = \Artisan::call("group:suspend {$group->email}"); + $output = trim(\Artisan::output()); + + $this->assertSame(0, $code); + $this->assertSame("", $output); + $this->assertTrue($group->fresh()->isSuspended()); + $event = EventLog::where('object_id', $group->id)->where('object_type', Group::class)->first(); + $this->assertSame(null, $event->comment); + $this->assertSame(EventLog::TYPE_SUSPENDED, $event->type); + + $group->unsuspend(); + EventLog::truncate(); + + // Test success (no --comment) + $code = \Artisan::call("group:suspend --comment=\"Test comment\" {$group->email}"); + $output = trim(\Artisan::output()); + + $this->assertSame(0, $code); + $this->assertSame("", $output); + $this->assertTrue($group->fresh()->isSuspended()); + $event = EventLog::where('object_id', $group->id)->where('object_type', Group::class)->first(); + $this->assertSame('Test comment', $event->comment); + $this->assertSame(EventLog::TYPE_SUSPENDED, $event->type); + } +} diff --git a/src/tests/Feature/Console/Group/UnsuspendTest.php b/src/tests/Feature/Console/Group/UnsuspendTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Console/Group/UnsuspendTest.php @@ -0,0 +1,76 @@ +<?php + +namespace Tests\Feature\Console\Group; + +use App\EventLog; +use App\Group; +use Illuminate\Support\Facades\Queue; +use Tests\TestCase; + +class UnsuspendTest extends TestCase +{ + /** + * {@inheritDoc} + */ + public function setUp(): void + { + parent::setUp(); + + $this->deleteTestGroup('group-test@kolabnow.com'); + EventLog::truncate(); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestGroup('group-test@kolabnow.com'); + EventLog::truncate(); + + parent::tearDown(); + } + + /** + * Test the command + */ + public function testHandle(): void + { + Queue::fake(); + + // Non-existing user + $code = \Artisan::call("group:unsuspend unknown"); + $output = trim(\Artisan::output()); + + $this->assertSame(1, $code); + $this->assertSame("Group not found.", $output); + + $group = $this->getTestGroup('group-test@kolabnow.com'); + $group->suspend(); + + // Test success (no --comment) + $code = \Artisan::call("group:unsuspend {$group->email}"); + $output = trim(\Artisan::output()); + + $this->assertSame(0, $code); + $this->assertSame("", $output); + $this->assertFalse($group->fresh()->isSuspended()); + $event = EventLog::where('object_id', $group->id)->where('object_type', Group::class)->first(); + $this->assertSame(null, $event->comment); + $this->assertSame(EventLog::TYPE_UNSUSPENDED, $event->type); + + $group->suspend(); + EventLog::truncate(); + + // Test success (no --comment) + $code = \Artisan::call("group:unsuspend --comment=\"Test comment\" {$group->email}"); + $output = trim(\Artisan::output()); + + $this->assertSame(0, $code); + $this->assertSame("", $output); + $this->assertFalse($group->fresh()->isSuspended()); + $event = EventLog::where('object_id', $group->id)->where('object_type', Group::class)->first(); + $this->assertSame('Test comment', $event->comment); + $this->assertSame(EventLog::TYPE_UNSUSPENDED, $event->type); + } +} diff --git a/src/tests/Feature/Controller/Admin/DomainsTest.php b/src/tests/Feature/Controller/Admin/DomainsTest.php --- a/src/tests/Feature/Controller/Admin/DomainsTest.php +++ b/src/tests/Feature/Controller/Admin/DomainsTest.php @@ -4,6 +4,7 @@ use App\Domain; use App\Entitlement; +use App\EventLog; use App\Sku; use Illuminate\Support\Facades\Queue; use Tests\TestCase; @@ -201,7 +202,7 @@ $this->assertFalse($domain->fresh()->isSuspended()); - // Test suspending the user + // Test suspending the domain $response = $this->actingAs($admin)->post("/api/v4/domains/{$domain->id}/suspend", []); $response->assertStatus(200); @@ -210,7 +211,23 @@ $this->assertSame('success', $json['status']); $this->assertSame("Domain suspended successfully.", $json['message']); $this->assertCount(2, $json); + $this->assertTrue($domain->fresh()->isSuspended()); + + $domain->unsuspend(); + EventLog::truncate(); + + // Test suspending the domain with a comment + $response = $this->actingAs($admin)->post("/api/v4/domains/{$domain->id}/suspend", ['comment' => 'Test']); + $response->assertStatus(200); + + $where = [ + 'object_id' => $domain->id, + 'object_type' => Domain::class, + 'type' => EventLog::TYPE_SUSPENDED, + 'comment' => 'Test' + ]; + $this->assertSame(1, EventLog::where($where)->count()); $this->assertTrue($domain->fresh()->isSuspended()); } @@ -234,7 +251,7 @@ $this->assertTrue($domain->fresh()->isSuspended()); - // Test suspending the user + // Test suspending the domain $response = $this->actingAs($admin)->post("/api/v4/domains/{$domain->id}/unsuspend", []); $response->assertStatus(200); @@ -243,7 +260,23 @@ $this->assertSame('success', $json['status']); $this->assertSame("Domain unsuspended successfully.", $json['message']); $this->assertCount(2, $json); + $this->assertFalse($domain->fresh()->isSuspended()); + + $domain->suspend(); + EventLog::truncate(); + + // Test unsuspending the domain with a comment + $response = $this->actingAs($admin)->post("/api/v4/domains/{$domain->id}/unsuspend", ['comment' => 'Test']); + $response->assertStatus(200); + + $where = [ + 'object_id' => $domain->id, + 'object_type' => Domain::class, + 'type' => EventLog::TYPE_UNSUSPENDED, + 'comment' => 'Test' + ]; + $this->assertSame(1, EventLog::where($where)->count()); $this->assertFalse($domain->fresh()->isSuspended()); } } diff --git a/src/tests/Feature/Controller/Admin/EventLogTest.php b/src/tests/Feature/Controller/Admin/EventLogTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Controller/Admin/EventLogTest.php @@ -0,0 +1,97 @@ +<?php + +namespace Tests\Feature\Controller\Admin; + +use App\EventLog; +use Tests\TestCase; + +class EventLogTest extends TestCase +{ + /** + * {@inheritDoc} + */ + public function setUp(): void + { + parent::setUp(); + self::useAdminUrl(); + + EventLog::query()->delete(); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + EventLog::query()->delete(); + + parent::tearDown(); + } + + /** + * Test listing events for a user (GET /api/v4/eventlog/user/{user}) + */ + public function testUserLog(): void + { + $user = $this->getTestUser('john@kolab.org'); + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + + // Non-admin user + $response = $this->actingAs($user)->get("api/v4/eventlog/user/{$user->id}"); + $response->assertStatus(403); + + // Admin user (unknown object type) + $response = $this->actingAs($admin)->get("api/v4/eventlog/eeee/{$user->id}"); + $response->assertStatus(404); + + // Admin user (empty list) + $response = $this->actingAs($admin)->get("api/v4/eventlog/user/{$user->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(0, $json['count']); + $this->assertSame([], $json['list']); + $this->assertFalse($json['hasMore']); + + // Non-empty list + $event1 = EventLog::createFor($user, EventLog::TYPE_SUSPENDED, "Event 1", ['test' => 'test1']); + $event1->created_at = now(); + $event1->save(); + $event2 = EventLog::createFor($user, EventLog::TYPE_UNSUSPENDED, "Event 2", ['test' => 'test2']); + $event2->created_at = (clone now())->subDay(); + $event2->save(); + + $response = $this->actingAs($admin)->get("api/v4/eventlog/user/{$user->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(2, $json['count']); + $this->assertCount(2, $json['list']); + $this->assertFalse($json['hasMore']); + $this->assertSame($event1->id, $json['list'][0]['id']); + $this->assertSame($event1->comment, $json['list'][0]['comment']); + $this->assertSame($event1->data, $json['list'][0]['data']); + $this->assertSame($admin->email, $json['list'][0]['user']); + $this->assertSame('Suspended', $json['list'][0]['event']); + $this->assertSame($event2->id, $json['list'][1]['id']); + $this->assertSame($event2->comment, $json['list'][1]['comment']); + $this->assertSame($event2->data, $json['list'][1]['data']); + $this->assertSame($admin->email, $json['list'][1]['user']); + $this->assertSame('Unsuspended', $json['list'][1]['event']); + + // A user in another tenant + $user = $this->getTestUser('user@sample-tenant.dev-local'); + $event3 = EventLog::createFor($user, EventLog::TYPE_SUSPENDED, "Event 3"); + + $response = $this->actingAs($admin)->get("api/v4/eventlog/user/{$user->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(1, $json['count']); + $this->assertCount(1, $json['list']); + $this->assertSame($event3->id, $json['list'][0]['id']); + } +} diff --git a/src/tests/Feature/Controller/Admin/GroupsTest.php b/src/tests/Feature/Controller/Admin/GroupsTest.php --- a/src/tests/Feature/Controller/Admin/GroupsTest.php +++ b/src/tests/Feature/Controller/Admin/GroupsTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature\Controller\Admin; +use App\EventLog; use App\Group; use Illuminate\Support\Facades\Queue; use Tests\TestCase; @@ -184,7 +185,23 @@ $this->assertSame('success', $json['status']); $this->assertSame("Distribution list suspended successfully.", $json['message']); $this->assertCount(2, $json); + $this->assertTrue($group->fresh()->isSuspended()); + + $group->unsuspend(); + EventLog::truncate(); + + // Test suspending the group with a comment + $response = $this->actingAs($admin)->post("/api/v4/groups/{$group->id}/suspend", ['comment' => 'Test']); + $response->assertStatus(200); + + $where = [ + 'object_id' => $group->id, + 'object_type' => Group::class, + 'type' => EventLog::TYPE_SUSPENDED, + 'comment' => 'Test' + ]; + $this->assertSame(1, EventLog::where($where)->count()); $this->assertTrue($group->fresh()->isSuspended()); } @@ -221,7 +238,23 @@ $this->assertSame('success', $json['status']); $this->assertSame("Distribution list unsuspended successfully.", $json['message']); $this->assertCount(2, $json); + $this->assertFalse($group->fresh()->isSuspended()); + + $group->unsuspend(); + EventLog::truncate(); + + // Test unsuspending the group with a comment + $response = $this->actingAs($admin)->post("/api/v4/groups/{$group->id}/unsuspend", ['comment' => 'Test']); + $response->assertStatus(200); + + $where = [ + 'object_id' => $group->id, + 'object_type' => Group::class, + 'type' => EventLog::TYPE_UNSUSPENDED, + 'comment' => 'Test' + ]; + $this->assertSame(1, EventLog::where($where)->count()); $this->assertFalse($group->fresh()->isSuspended()); } } diff --git a/src/tests/Feature/Controller/Admin/UsersTest.php b/src/tests/Feature/Controller/Admin/UsersTest.php --- a/src/tests/Feature/Controller/Admin/UsersTest.php +++ b/src/tests/Feature/Controller/Admin/UsersTest.php @@ -3,7 +3,9 @@ namespace Tests\Feature\Controller\Admin; use App\Auth\SecondFactor; +use App\EventLog; use App\Sku; +use App\User; use Illuminate\Support\Facades\Queue; use Tests\TestCase; @@ -503,7 +505,23 @@ $this->assertSame('success', $json['status']); $this->assertSame("User suspended successfully.", $json['message']); $this->assertCount(2, $json); + $this->assertTrue($user->fresh()->isSuspended()); + + $user->unsuspend(); + EventLog::truncate(); + + // Test suspending the user with a comment + $response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/suspend", ['comment' => 'Test']); + $response->assertStatus(200); + + $where = [ + 'object_id' => $user->id, + 'object_type' => User::class, + 'type' => EventLog::TYPE_SUSPENDED, + 'comment' => 'Test' + ]; + $this->assertSame(1, EventLog::where($where)->count()); $this->assertTrue($user->fresh()->isSuspended()); } @@ -534,7 +552,23 @@ $this->assertSame('success', $json['status']); $this->assertSame("User unsuspended successfully.", $json['message']); $this->assertCount(2, $json); + $this->assertFalse($user->fresh()->isSuspended()); + + $user->suspend(); + EventLog::truncate(); + + // Test suspending the user with a comment + $response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/unsuspend", ['comment' => 'Test']); + $response->assertStatus(200); + + $where = [ + 'object_id' => $user->id, + 'object_type' => User::class, + 'type' => EventLog::TYPE_UNSUSPENDED, + 'comment' => 'Test' + ]; + $this->assertSame(1, EventLog::where($where)->count()); $this->assertFalse($user->fresh()->isSuspended()); } diff --git a/src/tests/Feature/Controller/Reseller/EventLogTest.php b/src/tests/Feature/Controller/Reseller/EventLogTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Controller/Reseller/EventLogTest.php @@ -0,0 +1,106 @@ +<?php + +namespace Tests\Feature\Controller\Reseller; + +use App\EventLog; +use Tests\TestCase; + +class EventLogTest extends TestCase +{ + /** + * {@inheritDoc} + */ + public function setUp(): void + { + parent::setUp(); + self::useResellerUrl(); + + EventLog::query()->delete(); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + EventLog::query()->delete(); + + parent::tearDown(); + } + + /** + * Test listing events for a user (GET /api/v4/eventlog/user/{user}) + */ + public function testUserLog(): void + { + $user = $this->getTestUser('john@kolab.org'); + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); + $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); + + // Non-admin user + $response = $this->actingAs($user)->get("api/v4/eventlog/user/{$user->id}"); + $response->assertStatus(403); + + // Admin user + $response = $this->actingAs($admin)->get("api/v4/eventlog/user/{$user->id}"); + $response->assertStatus(403); + + // Reseller user (unknown object type) + $response = $this->actingAs($reseller1)->get("api/v4/eventlog/eeee/{$user->id}"); + $response->assertStatus(404); + + // Reseller user (empty list) + $response = $this->actingAs($reseller1)->get("api/v4/eventlog/user/{$user->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(0, $json['count']); + $this->assertSame([], $json['list']); + $this->assertFalse($json['hasMore']); + + // Non-empty list + $event1 = EventLog::createFor($user, EventLog::TYPE_SUSPENDED, "Event 1", ['test' => 'test1']); + $event1->created_at = now(); + $event1->save(); + $event2 = EventLog::createFor($user, EventLog::TYPE_UNSUSPENDED, "Event 2", ['test' => 'test2']); + $event2->created_at = (clone now())->subDay(); + $event2->save(); + + $response = $this->actingAs($reseller1)->get("api/v4/eventlog/user/{$user->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(2, $json['count']); + $this->assertCount(2, $json['list']); + $this->assertFalse($json['hasMore']); + $this->assertSame($event1->id, $json['list'][0]['id']); + $this->assertSame($event1->comment, $json['list'][0]['comment']); + $this->assertSame($event1->data, $json['list'][0]['data']); + $this->assertSame($reseller1->email, $json['list'][0]['user']); + $this->assertSame('Suspended', $json['list'][0]['event']); + $this->assertSame($event2->id, $json['list'][1]['id']); + $this->assertSame($event2->comment, $json['list'][1]['comment']); + $this->assertSame($event2->data, $json['list'][1]['data']); + $this->assertSame($reseller1->email, $json['list'][1]['user']); + $this->assertSame('Unsuspended', $json['list'][1]['event']); + + // A user in another tenant + $user = $this->getTestUser('user@sample-tenant.dev-local'); + $event3 = EventLog::createFor($user, EventLog::TYPE_SUSPENDED, "Event 3"); + + $response = $this->actingAs($reseller1)->get("api/v4/eventlog/user/{$user->id}"); + $response->assertStatus(404); + + $response = $this->actingAs($reseller2)->get("api/v4/eventlog/user/{$user->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(1, $json['count']); + $this->assertCount(1, $json['list']); + $this->assertSame($event3->id, $json['list'][0]['id']); + } +} diff --git a/src/tests/Feature/DomainTest.php b/src/tests/Feature/DomainTest.php --- a/src/tests/Feature/DomainTest.php +++ b/src/tests/Feature/DomainTest.php @@ -4,6 +4,7 @@ use App\Domain; use App\Entitlement; +use App\EventLog; use App\Sku; use App\User; use App\Tenant; @@ -231,6 +232,29 @@ $this->assertCount(0, Domain::withTrashed()->where('id', $domain->id)->get()); } + /** + * Test eventlog on domain deletion + */ + public function testDeleteAndEventLog(): void + { + Queue::fake(); + + $domain = $this->getTestDomain('gmail.com', [ + 'status' => Domain::STATUS_NEW, + 'type' => Domain::TYPE_PUBLIC, + ]); + + EventLog::createFor($domain, EventLog::TYPE_SUSPENDED, 'test'); + + $domain->delete(); + + $this->assertCount(1, EventLog::where('object_id', $domain->id)->where('object_type', Domain::class)->get()); + + $domain->forceDelete(); + + $this->assertCount(0, EventLog::where('object_id', $domain->id)->where('object_type', Domain::class)->get()); + } + /** * Test isEmpty() method */ diff --git a/src/tests/Feature/GroupTest.php b/src/tests/Feature/GroupTest.php --- a/src/tests/Feature/GroupTest.php +++ b/src/tests/Feature/GroupTest.php @@ -3,6 +3,7 @@ namespace Tests\Feature; use App\Group; +use App\EventLog; use Illuminate\Support\Facades\Queue; use Tests\TestCase; @@ -132,6 +133,26 @@ ); } + /** + * Test eventlog on group deletion + */ + public function testDeleteAndEventLog(): void + { + Queue::fake(); + + $group = $this->getTestGroup('group-test@kolabnow.com'); + + EventLog::createFor($group, EventLog::TYPE_SUSPENDED, 'test'); + + $group->delete(); + + $this->assertCount(1, EventLog::where('object_id', $group->id)->where('object_type', Group::class)->get()); + + $group->forceDelete(); + + $this->assertCount(0, EventLog::where('object_id', $group->id)->where('object_type', Group::class)->get()); + } + /** * Tests for Group::emailExists() */ 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 @@ -3,6 +3,7 @@ namespace Tests\Feature; use App\Domain; +use App\EventLog; use App\Group; use App\Package; use App\PackageSku; @@ -838,6 +839,26 @@ $this->assertCount(0, \App\SharedFolder::withTrashed()->where('id', $folder->id)->get()); } + /** + * Test eventlog on user deletion + */ + public function testDeleteAndEventLog(): void + { + Queue::fake(); + + $user = $this->getTestUser('user-test@' . \config('app.domain')); + + EventLog::createFor($user, EventLog::TYPE_SUSPENDED, 'test'); + + $user->delete(); + + $this->assertCount(1, EventLog::where('object_id', $user->id)->where('object_type', User::class)->get()); + + $user->forceDelete(); + + $this->assertCount(0, EventLog::where('object_id', $user->id)->where('object_type', User::class)->get()); + } + /** * Test user deletion vs. group membership */ diff --git a/src/tests/Unit/EventLogTest.php b/src/tests/Unit/EventLogTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Unit/EventLogTest.php @@ -0,0 +1,29 @@ +<?php + +namespace Tests\Unit; + +use App\EventLog; +use Tests\TestCase; + +class EventLogTest extends TestCase +{ + /** + * Test type mutator + */ + public function testSetTypeAttribute(): void + { + $event = new EventLog(); + + $this->expectException(\Exception::class); + $event->type = -1; + + $this->expectException(\Exception::class); + $event->type = 256; + + $this->expectException(\Exception::class); + $event->type = 'abc'; // @phpstan-ignore-line + + $event->type = 2; + $this->assertSame(20, $event->type); + } +}