diff --git a/src/app/Console/Commands/Domain/SuspendCommand.php b/src/app/Console/Commands/Domain/SuspendCommand.php index 18f992aa..d15d7d9b 100644 --- a/src/app/Console/Commands/Domain/SuspendCommand.php +++ b/src/app/Console/Commands/Domain/SuspendCommand.php @@ -1,39 +1,41 @@ getDomain($this->argument('domain')); if (!$domain) { $this->error("Domain not found."); return 1; } $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 index ae7ace08..cf7a6578 100644 --- a/src/app/Console/Commands/Domain/UnsuspendCommand.php +++ b/src/app/Console/Commands/Domain/UnsuspendCommand.php @@ -1,39 +1,41 @@ getDomain($this->argument('domain')); if (!$domain) { $this->error("Domain not found."); return 1; } $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 index 00000000..c974af8b --- /dev/null +++ b/src/app/Console/Commands/Group/SuspendCommand.php @@ -0,0 +1,41 @@ +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 index 00000000..ad7aacd1 --- /dev/null +++ b/src/app/Console/Commands/Group/UnsuspendCommand.php @@ -0,0 +1,41 @@ +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 index 6bcbfa63..436dc358 100644 --- a/src/app/Console/Commands/User/SuspendCommand.php +++ b/src/app/Console/Commands/User/SuspendCommand.php @@ -1,39 +1,41 @@ getUser($this->argument('user')); if (!$user) { $this->error("User not found."); return 1; } $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 index b18f157f..8605e240 100644 --- a/src/app/Console/Commands/User/UnsuspendCommand.php +++ b/src/app/Console/Commands/User/UnsuspendCommand.php @@ -1,39 +1,41 @@ getUser($this->argument('user')); if (!$user) { $this->error("User not found."); return 1; } $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 index 00000000..04f43908 --- /dev/null +++ b/src/app/EventLog.php @@ -0,0 +1,131 @@ + 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 Casts properties as type */ + protected $casts = [ + 'created_at' => 'datetime:Y-m-d H:i:s', + 'data' => 'array', + 'type' => 'integer', + ]; + + /** @var array 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 index e75c1b03..35ec33d7 100644 --- a/src/app/Http/Controllers/API/V4/Admin/DomainsController.php +++ b/src/app/Http/Controllers/API/V4/Admin/DomainsController.php @@ -1,128 +1,146 @@ errorResponse(404); } /** * Search for domains * * @return \Illuminate\Http\JsonResponse */ public function index() { $search = trim(request()->input('search')); $owner = trim(request()->input('owner')); $result = collect([]); if ($owner) { if ($owner = User::find($owner)) { foreach ($owner->wallets as $wallet) { $entitlements = $wallet->entitlements()->where('entitleable_type', Domain::class)->get(); foreach ($entitlements as $entitlement) { $domain = $entitlement->entitleable; $result->push($domain); } } $result = $result->sortBy('namespace')->values(); } } elseif (!empty($search)) { if ($domain = Domain::where('namespace', $search)->first()) { $result->push($domain); } } // Process the result $result = $result->map( function ($domain) { return $this->objectToClient($domain); } ); $result = [ 'list' => $result, 'count' => count($result), 'message' => self::trans('app.search-foundxdomains', ['x' => count($result)]), ]; return response()->json($result); } /** * Create a domain. * * @param \Illuminate\Http\Request $request * * @return \Illuminate\Http\JsonResponse */ public function store(Request $request) { return $this->errorResponse(404); } /** * Suspend the domain * * @param \Illuminate\Http\Request $request The API request. * @param string $id Domain identifier * * @return \Illuminate\Http\JsonResponse The response */ public function suspend(Request $request, $id) { $domain = Domain::find($id); if (!$this->checkTenant($domain) || $domain->isPublic()) { 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'), ]); } /** * Un-Suspend the domain * * @param \Illuminate\Http\Request $request The API request. * @param string $id Domain identifier * * @return \Illuminate\Http\JsonResponse The response */ public function unsuspend(Request $request, $id) { $domain = Domain::find($id); if (!$this->checkTenant($domain) || $domain->isPublic()) { 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 index 00000000..d3083f4f --- /dev/null +++ b/src/app/Http/Controllers/API/V4/Admin/EventLogController.php @@ -0,0 +1,67 @@ +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 index 29752cdc..c4ce94fc 100644 --- a/src/app/Http/Controllers/API/V4/Admin/GroupsController.php +++ b/src/app/Http/Controllers/API/V4/Admin/GroupsController.php @@ -1,107 +1,125 @@ input('search')); $owner = trim(request()->input('owner')); $result = collect([]); if ($owner) { if ($owner = User::find($owner)) { $result = $owner->groups(false)->orderBy('name')->get(); } } elseif (!empty($search)) { if ($group = Group::where('email', $search)->first()) { $result->push($group); } } // Process the result $result = $result->map( function ($group) { return $this->objectToClient($group); } ); $result = [ 'list' => $result, 'count' => count($result), 'message' => self::trans('app.search-foundxdistlists', ['x' => count($result)]), ]; return response()->json($result); } /** * Create a new group. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function store(Request $request) { return $this->errorResponse(404); } /** * Suspend a group * * @param \Illuminate\Http\Request $request The API request. * @param string $id Group identifier * * @return \Illuminate\Http\JsonResponse The response */ public function suspend(Request $request, $id) { $group = Group::find($id); if (!$this->checkTenant($group)) { 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'), ]); } /** * Un-Suspend a group * * @param \Illuminate\Http\Request $request The API request. * @param string $id Group identifier * * @return \Illuminate\Http\JsonResponse The response */ public function unsuspend(Request $request, $id) { $group = Group::find($id); if (!$this->checkTenant($group)) { 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 index 2d6f196f..a684b7fc 100644 --- a/src/app/Http/Controllers/API/V4/Admin/UsersController.php +++ b/src/app/Http/Controllers/API/V4/Admin/UsersController.php @@ -1,407 +1,424 @@ errorResponse(404); } /** * Searching of user accounts. * * @return \Illuminate\Http\JsonResponse */ public function index() { $search = trim(request()->input('search')); $owner = trim(request()->input('owner')); $result = collect([]); if ($owner) { $owner = User::find($owner); if ($owner) { $result = $owner->users(false)->orderBy('email')->get(); } } elseif (strpos($search, '@')) { // Search by email $result = User::withTrashed()->where('email', $search) ->orderBy('email') ->get(); if ($result->isEmpty()) { // Search by an alias $user_ids = \App\UserAlias::where('alias', $search)->get()->pluck('user_id'); // Search by an external email $ext_user_ids = \App\UserSetting::where('key', 'external_email') ->where('value', $search) ->get() ->pluck('user_id'); $user_ids = $user_ids->merge($ext_user_ids)->unique(); // Search by an email of a group, resource, shared folder, etc. if ($group = \App\Group::withTrashed()->where('email', $search)->first()) { $user_ids = $user_ids->merge([$group->wallet()->user_id])->unique(); } elseif ($resource = \App\Resource::withTrashed()->where('email', $search)->first()) { $user_ids = $user_ids->merge([$resource->wallet()->user_id])->unique(); } elseif ($folder = \App\SharedFolder::withTrashed()->where('email', $search)->first()) { $user_ids = $user_ids->merge([$folder->wallet()->user_id])->unique(); } elseif ($alias = \App\SharedFolderAlias::where('alias', $search)->first()) { $user_ids = $user_ids->merge([$alias->sharedFolder->wallet()->user_id])->unique(); } if (!$user_ids->isEmpty()) { $result = User::withTrashed()->whereIn('id', $user_ids) ->orderBy('email') ->get(); } } } elseif (is_numeric($search)) { // Search by user ID $user = User::withTrashed()->where('id', $search) ->first(); if ($user) { $result->push($user); } } elseif (strpos($search, '.') !== false) { // Search by domain $domain = Domain::withTrashed()->where('namespace', $search) ->first(); if ($domain) { if (($wallet = $domain->wallet()) && ($owner = $wallet->owner()->withTrashed()->first())) { $result->push($owner); } } // A mollie customer ID } elseif (substr($search, 0, 4) == 'cst_') { $setting = \App\WalletSetting::where( [ 'key' => 'mollie_id', 'value' => $search ] )->first(); if ($setting) { if ($wallet = $setting->wallet) { if ($owner = $wallet->owner()->withTrashed()->first()) { $result->push($owner); } } } // A mollie transaction ID } elseif (substr($search, 0, 3) == 'tr_') { $payment = \App\Payment::find($search); if ($payment) { if ($owner = $payment->wallet->owner()->withTrashed()->first()) { $result->push($owner); } } } elseif (!empty($search)) { $wallet = Wallet::find($search); if ($wallet) { if ($owner = $wallet->owner()->withTrashed()->first()) { $result->push($owner); } } } // Process the result $result = $result->map( function ($user) { return $this->objectToClient($user, true); } ); $result = [ 'list' => $result, 'count' => count($result), 'message' => self::trans('app.search-foundxusers', ['x' => count($result)]), ]; return response()->json($result); } /** * Reset 2-Factor Authentication for the user * * @param \Illuminate\Http\Request $request The API request. * @param string $id User identifier * * @return \Illuminate\Http\JsonResponse The response */ public function reset2FA(Request $request, $id) { $user = User::find($id); if (!$this->checkTenant($user)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canUpdate($user)) { return $this->errorResponse(403); } $sku = Sku::withObjectTenantContext($user)->where('title', '2fa')->first(); // Note: we do select first, so the observer can delete // 2FA preferences from Roundcube database, so don't // be tempted to replace first() with delete() below $entitlement = $user->entitlements()->where('sku_id', $sku->id)->first(); $entitlement->delete(); return response()->json([ 'status' => 'success', 'message' => self::trans('app.user-reset-2fa-success'), ]); } /** * Reset Geo-Lockin for the user * * @param \Illuminate\Http\Request $request The API request. * @param string $id User identifier * * @return \Illuminate\Http\JsonResponse The response */ public function resetGeoLock(Request $request, $id) { $user = User::find($id); if (!$this->checkTenant($user)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canUpdate($user)) { return $this->errorResponse(403); } $user->setConfig(['limit_geo' => []]); return response()->json([ 'status' => 'success', 'message' => self::trans('app.user-reset-geo-lock-success'), ]); } /** * Resync the user * * @param \Illuminate\Http\Request $request The API request. * @param string $id User identifier * * @return \Illuminate\Http\JsonResponse The response */ public function resync(Request $request, $id) { $user = User::find($id); if (!$this->checkTenant($user)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canUpdate($user)) { return $this->errorResponse(403); } if (\Artisan::call('user:resync', ['user' => $user->id])) { return $this->errorResponse(500); } return response()->json([ 'status' => 'success', 'message' => self::trans('app.user-resync-success'), ]); } /** * Set/Add a SKU for the user * * @param \Illuminate\Http\Request $request The API request. * @param string $id User identifier * @param string $sku SKU title * * @return \Illuminate\Http\JsonResponse The response */ public function setSku(Request $request, $id, $sku) { // For now we allow adding the 'beta' SKU only if ($sku != 'beta') { return $this->errorResponse(404); } $user = User::find($id); if (!$this->checkTenant($user)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canUpdate($user)) { return $this->errorResponse(403); } $sku = Sku::withObjectTenantContext($user)->where('title', $sku)->first(); if (!$sku) { return $this->errorResponse(404); } if ($user->entitlements()->where('sku_id', $sku->id)->first()) { return $this->errorResponse(422, self::trans('app.user-set-sku-already-exists')); } $user->assignSku($sku); /** @var \App\Entitlement $entitlement */ $entitlement = $user->entitlements()->where('sku_id', $sku->id)->first(); return response()->json([ 'status' => 'success', 'message' => self::trans('app.user-set-sku-success'), 'sku' => [ 'cost' => $entitlement->cost, 'name' => $sku->name, 'id' => $sku->id, ] ]); } /** * Create a new user record. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function store(Request $request) { return $this->errorResponse(404); } /** * Suspend the user * * @param \Illuminate\Http\Request $request The API request. * @param string $id User identifier * * @return \Illuminate\Http\JsonResponse The response */ public function suspend(Request $request, $id) { $user = User::find($id); if (!$this->checkTenant($user)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canUpdate($user)) { 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'), ]); } /** * Un-Suspend the user * * @param \Illuminate\Http\Request $request The API request. * @param string $id User identifier * * @return \Illuminate\Http\JsonResponse The response */ public function unsuspend(Request $request, $id) { $user = User::find($id); if (!$this->checkTenant($user)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canUpdate($user)) { 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'), ]); } /** * Update user data. * * @param \Illuminate\Http\Request $request The API request. * @param string $id User identifier * * @return \Illuminate\Http\JsonResponse The response */ public function update(Request $request, $id) { $user = User::find($id); if (!$this->checkTenant($user)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canUpdate($user)) { return $this->errorResponse(403); } // For now admins can change only user external email address $rules = []; if (array_key_exists('external_email', $request->input())) { $rules['external_email'] = 'email'; } // Validate input $v = Validator::make($request->all(), $rules); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } // Update user settings $settings = $request->only(array_keys($rules)); if (!empty($settings)) { $user->setSettings($settings); } return response()->json([ 'status' => 'success', 'message' => self::trans('app.user-update-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 index 00000000..16276a7d --- /dev/null +++ b/src/app/Http/Controllers/API/V4/Reseller/EventLogController.php @@ -0,0 +1,7 @@ +isDeleted()`), or * * the user is actually deleted (`$user->deleted_at`), or * * the user is already marked as ready in LDAP (`$user->isLdapReady()`). * */ class CreateJob extends UserJob { /** * Execute the job. * * @return void * * @throws \Exception */ public function handle() { $user = $this->getUser(); if (!$user) { return; } if ($user->role) { // Admins/resellers don't reside in LDAP (for now) return; } if ($user->email == \config('imap.admin_login')) { // Ignore Cyrus admin account return; } // sanity checks if ($user->isDeleted()) { $this->fail(new \Exception("User {$this->userId} is marked as deleted.")); return; } if ($user->trashed()) { $this->fail(new \Exception("User {$this->userId} is actually deleted.")); return; } $withLdap = \config('app.with_ldap'); // see if the domain is ready $domain = $user->domain(); if (!$domain) { $this->fail(new \Exception("The domain for {$this->userId} does not exist.")); return; } if ($domain->isDeleted()) { $this->fail(new \Exception("The domain for {$this->userId} is marked as deleted.")); return; } if ($withLdap && !$domain->isLdapReady()) { $this->release(60); return; } if (\config('abuse.suspend_enabled') && !$user->isSuspended()) { $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; } } if ($withLdap && !$user->isLdapReady()) { \App\Backends\LDAP::createUser($user); $user->status |= \App\User::STATUS_LDAP_READY; $user->save(); } if (!$user->isImapReady()) { if (\config('app.with_imap')) { if (!\App\Backends\IMAP::createUser($user)) { throw new \Exception("Failed to create mailbox for user {$this->userId}."); } } else { if (!\App\Backends\IMAP::verifyAccount($user->email)) { $this->release(15); return; } } $user->status |= \App\User::STATUS_IMAP_READY; } // Make user active in non-mandate mode only if ( !($wallet = $user->wallet()) || !($plan = $user->wallet()->plan()) || $plan->mode != \App\Plan::MODE_MANDATE ) { $user->status |= \App\User::STATUS_ACTIVE; } $user->save(); } } diff --git a/src/app/Observers/DomainObserver.php b/src/app/Observers/DomainObserver.php index 0c171a61..e3aa9eaa 100644 --- a/src/app/Observers/DomainObserver.php +++ b/src/app/Observers/DomainObserver.php @@ -1,110 +1,113 @@ namespace = \strtolower($domain->namespace); $domain->status |= Domain::STATUS_NEW; } /** * Handle the domain "created" event. * * @param \App\Domain $domain The domain. * * @return void */ public function created(Domain $domain) { // Create domain record in LDAP // Note: DomainCreate job will dispatch DomainVerify job \App\Jobs\Domain\CreateJob::dispatch($domain->id); } /** * Handle the domain "deleted" event. * * @param \App\Domain $domain The domain. * * @return void */ 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; } \App\Jobs\Domain\DeleteJob::dispatch($domain->id); } /** * Handle the domain "deleting" event. * * @param \App\Domain $domain The domain. * * @return void */ public function deleting(Domain $domain) { \App\Policy\RateLimitWhitelist::where( [ 'whitelistable_id' => $domain->id, 'whitelistable_type' => Domain::class ] )->delete(); } /** * Handle the domain "updated" event. * * @param \App\Domain $domain The domain. * * @return void */ public function updated(Domain $domain) { \App\Jobs\Domain\UpdateJob::dispatch($domain->id); } /** * Handle the domain "restoring" event. * * @param \App\Domain $domain The domain. * * @return void */ public function restoring(Domain $domain) { // Reset the status $domain->status = Domain::STATUS_NEW; // Note: $domain->save() is invoked between 'restoring' and 'restored' events } /** * Handle the domain "restored" event. * * @param \App\Domain $domain The domain. * * @return void */ public function restored(Domain $domain) { // Create the domain in LDAP again \App\Jobs\Domain\CreateJob::dispatch($domain->id); } } diff --git a/src/app/Observers/EventLogObserver.php b/src/app/Observers/EventLogObserver.php new file mode 100644 index 00000000..d8ca3781 --- /dev/null +++ b/src/app/Observers/EventLogObserver.php @@ -0,0 +1,24 @@ +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 index a050ed02..dacef53e 100644 --- a/src/app/Observers/GroupObserver.php +++ b/src/app/Observers/GroupObserver.php @@ -1,92 +1,95 @@ status |= Group::STATUS_NEW; if (!isset($group->name) && isset($group->email)) { $group->name = explode('@', $group->email)[0]; } } /** * Handle the group "created" event. * * @param \App\Group $group The group * * @return void */ public function created(Group $group) { \App\Jobs\Group\CreateJob::dispatch($group->id); } /** * Handle the group "deleted" event. * * @param \App\Group $group The group * * @return void */ 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; } \App\Jobs\Group\DeleteJob::dispatch($group->id); } /** * Handle the group "updated" event. * * @param \App\Group $group The group * * @return void */ public function updated(Group $group) { \App\Jobs\Group\UpdateJob::dispatch($group->id); } /** * Handle the group "restoring" event. * * @param \App\Group $group The group * * @return void */ public function restoring(Group $group) { // Reset the status $group->status = Group::STATUS_NEW; // Note: $group->save() is invoked between 'restoring' and 'restored' events } /** * Handle the group "restored" event. * * @param \App\Group $group The group * * @return void */ public function restored(Group $group) { \App\Jobs\Group\CreateJob::dispatch($group->id); } } diff --git a/src/app/Observers/UserObserver.php b/src/app/Observers/UserObserver.php index 8f859898..1f2cc28b 100644 --- a/src/app/Observers/UserObserver.php +++ b/src/app/Observers/UserObserver.php @@ -1,317 +1,320 @@ email = \strtolower($user->email); $user->status |= User::STATUS_NEW; } /** * Handle the "created" event. * * Ensures the user has at least one wallet. * * Should ensure some basic settings are available as well. * * @param \App\User $user The user created. * * @return void */ public function created(User $user) { $settings = [ 'country' => \App\Utils::countryForRequest(), 'currency' => \config('app.currency'), /* 'first_name' => '', 'last_name' => '', 'billing_address' => '', 'organization' => '', 'phone' => '', 'external_email' => '', */ ]; foreach ($settings as $key => $value) { $settings[$key] = [ 'key' => $key, 'value' => $value, 'user_id' => $user->id, ]; } // Note: Don't use setSettings() here to bypass UserSetting observers // Note: This is a single multi-insert query $user->settings()->insert(array_values($settings)); $user->wallets()->create(); // Create user record in the backend (LDAP and IMAP) \App\Jobs\User\CreateJob::dispatch($user->id); if (\App\Tenant::getConfig($user->tenant_id, 'pgp.enable')) { \App\Jobs\PGP\KeyCreateJob::dispatch($user->id, $user->email); } } /** * Handle the "deleted" event. * * @param \App\User $user The user deleted. * * @return void */ public function deleted(User $user) { // Remove the user from existing groups $wallet = $user->wallet(); if ($wallet && $wallet->owner) { $wallet->owner->groups()->each(function ($group) use ($user) { if (in_array($user->email, $group->members)) { $group->members = array_diff($group->members, [$user->email]); $group->save(); } }); } // TODO: Remove Permission records for the user // TODO: Remove file permissions for the user } /** * Handle the "deleting" event. * * @param User $user The user that is being deleted. * * @return void */ public function deleting(User $user) { // Remove owned users/domains/groups/resources/etc self::removeRelatedObjects($user, $user->isForceDeleting()); // TODO: Especially in tests we're doing delete() on a already deleted user. // Should we escape here - for performance reasons? if (!$user->isForceDeleting()) { \App\Jobs\User\DeleteJob::dispatch($user->id); if (\App\Tenant::getConfig($user->tenant_id, 'pgp.enable')) { \App\Jobs\PGP\KeyDeleteJob::dispatch($user->id, $user->email); } // Debit the reseller's wallet with the user negative balance $balance = 0; foreach ($user->wallets as $wallet) { // Note: here we assume all user wallets are using the same currency. // It might get changed in the future $balance += $wallet->balance; } if ($balance < 0 && $user->tenant && ($wallet = $user->tenant->wallet())) { $wallet->debit($balance * -1, "Deleted user {$user->email}"); } } } /** * Handle the user "restoring" event. * * @param \App\User $user The user * * @return void */ public function restoring(User $user) { // Reset the status $user->status = User::STATUS_NEW; // Note: $user->save() is invoked between 'restoring' and 'restored' events } /** * Handle the user "restored" event. * * @param \App\User $user The user * * @return void */ public function restored(User $user) { // We need at least the user domain so it can be created in ldap. // FIXME: What if the domain is owned by someone else? $domain = $user->domain(); if ($domain->trashed() && !$domain->isPublic()) { // Note: Domain entitlements will be restored by the DomainObserver $domain->restore(); } // FIXME: Should we reset user aliases? or re-validate them in any way? // Create user record in the backend (LDAP and IMAP) \App\Jobs\User\CreateJob::dispatch($user->id); } /** * Handle the "updated" event. * * @param \App\User $user The user that is being updated. * * @return void */ public function updated(User $user) { \App\Jobs\User\UpdateJob::dispatch($user->id); $oldStatus = $user->getOriginal('status'); $newStatus = $user->status; if (($oldStatus & User::STATUS_DEGRADED) !== ($newStatus & User::STATUS_DEGRADED)) { $wallets = []; $isDegraded = $user->isDegraded(); // Charge all entitlements as if they were being deleted, // but don't delete them. Just debit the wallet and update // entitlements' updated_at timestamp. On un-degrade we still // update updated_at, but with no debit (the cost is 0 on a degraded account). foreach ($user->wallets as $wallet) { $wallet->updateEntitlements($isDegraded); // Remember time of the degradation for sending periodic reminders // and reset it on un-degradation $val = $isDegraded ? \Carbon\Carbon::now()->toDateTimeString() : null; $wallet->setSetting('degraded_last_reminder', $val); $wallets[] = $wallet->id; } // (Un-)degrade users by invoking an update job. // LDAP backend will read the wallet owner's degraded status and // set LDAP attributes accordingly. // We do not change their status as their wallets have its own state \App\Entitlement::whereIn('wallet_id', $wallets) ->where('entitleable_id', '!=', $user->id) ->where('entitleable_type', User::class) ->pluck('entitleable_id') ->unique() ->each(function ($user_id) { \App\Jobs\User\UpdateJob::dispatch($user_id); }); } // Save the old password in the password history $oldPassword = $user->getOriginal('password'); if ($oldPassword && $user->password != $oldPassword) { self::saveOldPassword($user, $oldPassword); } } /** - * 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 */ private static function removeRelatedObjects(User $user, $force = false): void { $wallets = $user->wallets->pluck('id')->all(); \App\Entitlement::withTrashed() ->select('entitleable_id', 'entitleable_type') ->distinct() ->whereIn('wallet_id', $wallets) ->get() ->each(function ($entitlement) use ($user, $force) { // Skip the current user (infinite recursion loop) if ($entitlement->entitleable_type == User::class && $entitlement->entitleable_id == $user->id) { return; } if (!$entitlement->entitleable) { return; } // Objects need to be deleted one by one to make sure observers can do the proper cleanup if ($force) { $entitlement->entitleable->forceDelete(); } elseif (!$entitlement->entitleable->trashed()) { $entitlement->entitleable->delete(); } }); if ($force) { // Remove "wallet" transactions, they have no foreign key constraint \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 \App\Policy\RateLimitWhitelist::where( [ 'whitelistable_id' => $user->id, 'whitelistable_type' => User::class ] )->delete(); } /** * Store the old password in user password history. Make sure * we do not store more passwords than we need in the history. * * @param \App\User $user The user * @param string $password The old password */ private static function saveOldPassword(User $user, string $password): void { // Remember the timestamp of the last password change and unset the last warning date $user->setSettings([ 'password_expiration_warning' => null, // Note: We could get this from user_passwords table, but only if the policy // enables storing of old passwords there. 'password_update' => now()->format('Y-m-d H:i:s'), ]); // Note: All this is kinda heavy and complicated because we don't want to store // more old passwords than we need. However, except the complication/performance, // there's one issue with it. E.g. the policy changes from 2 to 4, and we already // removed the old passwords that were excessive before, but not now. // Get the account password policy $policy = new \App\Rules\Password($user->walletOwner()); $rules = $policy->rules(); // Password history disabled? if (empty($rules['last']) || $rules['last']['param'] < 2) { return; } // Store the old password $user->passwords()->create(['password' => $password]); // Remove passwords that we don't need anymore $limit = $rules['last']['param'] - 1; $ids = $user->passwords()->latest()->limit($limit)->pluck('id')->all(); if (count($ids) >= $limit) { $user->passwords()->where('id', '<', $ids[count($ids) - 1])->delete(); } } } diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php index 74e097e4..c907d203 100644 --- a/src/app/Providers/AppServiceProvider.php +++ b/src/app/Providers/AppServiceProvider.php @@ -1,188 +1,189 @@ format('Y-m-d h:i:s'); } elseif ($ipv && is_string($entry) && strlen($entry) == ($ipv == 6 ? 16 : 4)) { // binary IP address? use HEX representation return '0x' . bin2hex($entry); } return $entry; }, $array); return implode(', ', $serialized); } /** * Load the override config and apply it * * Create a config/override.php file with content like this: * return [ * 'imap.uri' => 'overrideValue1', * 'queue.connections.database.table' => 'overrideValue2', * ]; */ private function applyOverrideConfig(): void { $overrideConfig = (array) \config('override'); foreach (array_keys($overrideConfig) as $key) { \config([$key => $overrideConfig[$key]]); } } /** * Bootstrap any application services. */ public function boot(): void { \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); \App\PackageSku::observe(\App\Observers\PackageSkuObserver::class); \App\PlanPackage::observe(\App\Observers\PlanPackageObserver::class); \App\Resource::observe(\App\Observers\ResourceObserver::class); \App\ResourceSetting::observe(\App\Observers\ResourceSettingObserver::class); \App\SharedFolder::observe(\App\Observers\SharedFolderObserver::class); \App\SharedFolderAlias::observe(\App\Observers\SharedFolderAliasObserver::class); \App\SharedFolderSetting::observe(\App\Observers\SharedFolderSettingObserver::class); \App\SignupCode::observe(\App\Observers\SignupCodeObserver::class); \App\SignupInvitation::observe(\App\Observers\SignupInvitationObserver::class); \App\Transaction::observe(\App\Observers\TransactionObserver::class); \App\User::observe(\App\Observers\UserObserver::class); \App\UserAlias::observe(\App\Observers\UserAliasObserver::class); \App\UserSetting::observe(\App\Observers\UserSettingObserver::class); \App\VerificationCode::observe(\App\Observers\VerificationCodeObserver::class); \App\Wallet::observe(\App\Observers\WalletObserver::class); \App\PowerDNS\Domain::observe(\App\Observers\PowerDNS\DomainObserver::class); \App\PowerDNS\Record::observe(\App\Observers\PowerDNS\RecordObserver::class); Schema::defaultStringLength(191); // Log SQL queries in debug mode if (\config('app.debug')) { DB::listen(function ($query) { \Log::debug( sprintf( '[SQL] %s [%s]: %.4f sec.', $query->sql, self::serializeSQLBindings($query->bindings, $query->sql), $query->time / 1000 ) ); }); } // Register some template helpers Blade::directive( 'theme_asset', function ($path) { $path = trim($path, '/\'"'); return ""; } ); Builder::macro( 'withEnvTenantContext', function (string $table = null) { $tenantId = \config('app.tenant_id'); if ($tenantId) { /** @var Builder $this */ return $this->where(($table ? "$table." : "") . "tenant_id", $tenantId); } /** @var Builder $this */ return $this->whereNull(($table ? "$table." : "") . "tenant_id"); } ); Builder::macro( 'withObjectTenantContext', function (object $object, string $table = null) { $tenantId = $object->tenant_id; if ($tenantId) { /** @var Builder $this */ return $this->where(($table ? "$table." : "") . "tenant_id", $tenantId); } /** @var Builder $this */ return $this->whereNull(($table ? "$table." : "") . "tenant_id"); } ); Builder::macro( 'withSubjectTenantContext', function (string $table = null) { if ($user = auth()->user()) { $tenantId = $user->tenant_id; } else { $tenantId = \config('app.tenant_id'); } if ($tenantId) { /** @var Builder $this */ return $this->where(($table ? "$table." : "") . "tenant_id", $tenantId); } /** @var Builder $this */ return $this->whereNull(($table ? "$table." : "") . "tenant_id"); } ); // Query builder 'whereLike' mocro Builder::macro( 'whereLike', function (string $column, string $search, int $mode = 0) { $search = addcslashes($search, '%_'); switch ($mode) { case 2: $search .= '%'; break; case 1: $search = '%' . $search; break; default: $search = '%' . $search . '%'; } /** @var Builder $this */ return $this->where($column, 'like', $search); } ); $this->applyOverrideConfig(); } } 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 index 00000000..635291f9 --- /dev/null +++ b/src/database/migrations/2023_06_06_100000_create_eventlog_table.php @@ -0,0 +1,43 @@ +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 index 04922744..4e99f621 100644 --- a/src/resources/lang/en/app.php +++ b/src/resources/lang/en/app.php @@ -1,173 +1,177 @@ 'Created', 'chart-deleted' => 'Deleted', 'chart-average' => 'average', 'chart-allusers' => 'All Users - last year', 'chart-discounts' => 'Discounts', 'chart-vouchers' => 'Vouchers', 'chart-income' => 'Income in :currency - last 8 weeks', 'chart-payers' => 'Payers - last year', 'chart-users' => 'Users - last 8 weeks', '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.', 'mandate-description-suffix' => 'Auto-Payment Setup', 'planbutton' => 'Choose :plan', 'process-async' => 'Setup process has been pushed. Please wait.', 'process-user-new' => 'Registering a user...', 'process-user-ldap-ready' => 'Creating a user...', 'process-user-imap-ready' => 'Creating a mailbox...', 'process-domain-new' => 'Registering a custom domain...', 'process-domain-ldap-ready' => 'Creating a custom domain...', 'process-domain-verified' => 'Verifying a custom domain...', 'process-domain-confirmed' => 'Confirming an ownership of a custom domain...', 'process-success' => 'Setup process finished successfully.', 'process-error-distlist-ldap-ready' => 'Failed to create a distribution list.', 'process-error-domain-ldap-ready' => 'Failed to create a domain.', 'process-error-domain-verified' => 'Failed to verify a domain.', 'process-error-domain-confirmed' => 'Failed to confirm an ownership of a domain.', 'process-error-resource-imap-ready' => 'Failed to verify that a shared folder exists.', 'process-error-resource-ldap-ready' => 'Failed to create a resource.', 'process-error-shared-folder-imap-ready' => 'Failed to verify that a shared folder exists.', 'process-error-shared-folder-ldap-ready' => 'Failed to create a shared folder.', 'process-error-user-ldap-ready' => 'Failed to create a user.', 'process-error-user-imap-ready' => 'Failed to verify that a mailbox exists.', 'process-distlist-new' => 'Registering a distribution list...', 'process-distlist-ldap-ready' => 'Creating a distribution list...', 'process-resource-new' => 'Registering a resource...', 'process-resource-imap-ready' => 'Creating a shared folder...', 'process-resource-ldap-ready' => 'Creating a resource...', 'process-shared-folder-new' => 'Registering a shared folder...', 'process-shared-folder-imap-ready' => 'Creating a shared folder...', 'process-shared-folder-ldap-ready' => 'Creating a shared folder...', 'discount-code' => 'Discount: :code', 'distlist-update-success' => 'Distribution list updated successfully.', 'distlist-create-success' => 'Distribution list created successfully.', 'distlist-delete-success' => 'Distribution list deleted successfully.', 'distlist-suspend-success' => 'Distribution list suspended successfully.', 'distlist-unsuspend-success' => 'Distribution list unsuspended successfully.', 'distlist-setconfig-success' => 'Distribution list settings updated successfully.', 'domain-create-success' => 'Domain created successfully.', 'domain-delete-success' => 'Domain deleted successfully.', 'domain-notempty-error' => 'Unable to delete a domain with assigned users or other objects.', 'domain-confirm-success' => 'Domain ownership confirmed successfully.', 'domain-confirm-error' => 'Domain ownership confirmation failed.', 'domain-suspend-success' => 'Domain suspended successfully.', 'domain-unsuspend-success' => 'Domain unsuspended successfully.', 'domain-setconfig-success' => 'Domain settings updated successfully.', 'file-create-success' => 'File created successfully.', 'file-delete-success' => 'File deleted successfully.', 'file-update-success' => 'File updated successfully.', 'file-permissions-create-success' => 'File permissions created successfully.', 'file-permissions-update-success' => 'File permissions updated successfully.', 'file-permissions-delete-success' => 'File permissions deleted successfully.', 'collection-create-success' => 'Collection created successfully.', 'collection-delete-success' => 'Collection deleted successfully.', 'collection-update-success' => 'Collection updated successfully.', 'payment-status-paid' => 'The payment has been completed successfully.', 'payment-status-canceled' => 'The payment has been canceled.', 'payment-status-failed' => 'The payment failed.', 'payment-status-expired' => 'The payment expired.', 'payment-status-checking' => "The payment hasn't been completed yet. Checking the status...", 'period-year' => 'year', 'period-month' => 'month', 'resource-update-success' => 'Resource updated successfully.', 'resource-create-success' => 'Resource created successfully.', 'resource-delete-success' => 'Resource deleted successfully.', 'resource-setconfig-success' => 'Resource settings updated successfully.', 'room-update-success' => 'Room updated successfully.', 'room-create-success' => 'Room created successfully.', 'room-delete-success' => 'Room deleted successfully.', 'room-setconfig-success' => 'Room configuration updated successfully.', 'room-unsupported-option-error' => 'Invalid room configuration option.', 'shared-folder-update-success' => 'Shared folder updated successfully.', 'shared-folder-create-success' => 'Shared folder created successfully.', 'shared-folder-delete-success' => 'Shared folder deleted successfully.', 'shared-folder-setconfig-success' => 'Shared folder settings updated successfully.', 'user-update-success' => 'User data updated successfully.', 'user-create-success' => 'User created successfully.', 'user-delete-success' => 'User deleted successfully.', 'user-resync-success' => 'User synchronization has been started.', 'user-suspend-success' => 'User suspended successfully.', 'user-unsuspend-success' => 'User unsuspended successfully.', 'user-reset-2fa-success' => '2-Factor authentication reset successfully.', 'user-reset-geo-lock-success' => 'Geo-lockin setup reset successfully.', 'user-setconfig-success' => 'User settings updated successfully.', 'user-set-sku-success' => 'The subscription added successfully.', 'user-set-sku-already-exists' => 'The subscription already exists.', 'search-foundxdomains' => ':x domains have been found.', 'search-foundxdistlists' => ':x distribution lists have been found.', 'search-foundxresources' => ':x resources have been found.', 'search-foundxshared-folders' => ':x shared folders have been found.', 'search-foundxusers' => ':x user accounts have been found.', 'signup-account-mandate' => 'Now it is required to provide your credit card details.' . ' This way you agree to charge you with an appropriate amount of money according to the plan you signed up for.', 'signup-account-free' => 'You are signing up for an account with 100% discount. You will be redirected immediately to your Dashboard.', 'signup-plan-monthly' => 'You are choosing a monthly subscription.', 'signup-plan-yearly' => 'You are choosing a yearly subscription.', 'signup-subscription-monthly' => 'Monthly subscription', 'signup-subscription-yearly' => 'Yearly subscription', 'signup-invitations-created' => 'The invitation has been created.|:count invitations has been created.', 'signup-invitations-csv-empty' => 'Failed to find any valid email addresses in the uploaded file.', 'signup-invitations-csv-invalid-email' => 'Found an invalid email address (:email) on line :line.', 'signup-invitation-delete-success' => 'Invitation deleted successfully.', 'signup-invitation-resend-success' => 'Invitation added to the sending queue successfully.', 'support-request-success' => 'Support request submitted successfully.', 'support-request-error' => 'Failed to submit the support request.', 'siteuser' => ':site User', 'total' => 'Total', 'wallet-award-success' => 'The bonus has been added to the wallet successfully.', 'wallet-penalty-success' => 'The penalty has been added to the wallet successfully.', 'wallet-update-success' => 'User wallet updated successfully.', 'password-reset-code-delete-success' => 'Password reset code deleted successfully.', 'password-rule-min' => 'Minimum password length: :param characters', 'password-rule-max' => 'Maximum password length: :param characters', 'password-rule-lower' => 'Password contains a lower-case character', 'password-rule-upper' => 'Password contains an upper-case character', 'password-rule-digit' => 'Password contains a digit', 'password-rule-special' => 'Password contains a special character', 'password-rule-last' => 'Password cannot be the same as the last :param passwords', 'wallet-notice-date' => 'With your current subscriptions your account balance will last until about :date (:days).', 'wallet-notice-nocredit' => 'You are out of credit, top up your balance now.', 'wallet-notice-today' => 'You will run out of credit today, top up your balance now.', 'wallet-notice-trial' => 'You are in your free trial period.', 'wallet-notice-trial-end' => 'Your free trial is about to end, top up to continue.', 'vat-incl' => 'Incl. VAT :vat (:rate of :cost)', ]; diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php index 399dcffe..16c48900 100644 --- a/src/resources/lang/en/ui.php +++ b/src/resources/lang/en/ui.php @@ -1,576 +1,585 @@ [ 'faq' => "FAQ", ], 'btn' => [ 'add' => "Add", 'accept' => "Accept", 'back' => "Back", 'cancel' => "Cancel", 'close' => "Close", 'confirm' => "Confirm", 'continue' => "Continue", 'copy' => "Copy", 'delete' => "Delete", 'deny' => "Deny", 'download' => "Download", 'edit' => "Edit", 'file' => "Choose file...", 'moreinfo' => "More information", 'refresh' => "Refresh", 'reset' => "Reset", 'resend' => "Resend", 'resync' => "Resync", 'save' => "Save", 'search' => "Search", 'share' => "Share", 'signup' => "Sign Up", 'submit' => "Submit", 'subscribe' => "Subscribe", 'suspend' => "Suspend", 'tryagain' => "Try again", 'unsuspend' => "Unsuspend", 'verify' => "Verify", ], + 'collection' => [ + 'create' => "Create collection", + 'new' => "New Collection", + 'name' => "Name", + ], + 'companion' => [ 'title' => "Companion Apps", 'companion' => "Companion App", 'name' => "Name", 'create' => "Pair new device", 'create-recovery-device' => "Prepare recovery code", 'description' => "Use the Companion App on your mobile phone as multi-factor authentication device.", 'download-description' => "You may download the Companion App for Android here: " . "Download", 'description-detailed' => "Here is how this works: " . "Pairing a device will automatically enable multi-factor autentication for all login attempts. " . "This includes not only the Cockpit, but also logins via Webmail, IMAP, SMPT, DAV and ActiveSync. " . "Any authentication attempt will result in a notification on your device, " . "that you can use to confirm if it was you, or deny otherwise. " . "Once confirmed, the same username + IP address combination will be whitelisted for 8 hours. " . "Unpair all your active devices to disable multi-factor authentication again.", 'description-warning' => "Warning: Loosing access to all your multi-factor authentication devices, " . "will permanently lock you out of your account with no course for recovery. " . "Always make sure you have a recovery QR-Code printed to pair a recovery device.", 'new' => "Pair new device", 'recovery' => "Prepare recovery device", 'paired' => "Paired devices", 'print' => "Print for backup", 'pairing-instructions' => "Pair your device using the following QR-Code.", 'recovery-device' => "Recovery Device", 'new-device' => "New Device", 'deviceid' => "Device ID", 'list-empty' => "There are currently no devices", 'delete' => "Delete/Unpair", 'delete-companion' => "Delete/Unpair", 'delete-text' => "You are about to delete this entry and unpair any paired companion app. " . "This cannot be undone, but you can pair the device again.", 'pairing-successful' => "Your companion app is paired and ready to be used " . "as a multi-factor authentication device.", ], 'dashboard' => [ 'beta' => "beta", 'distlists' => "Distribution lists", 'chat' => "Video chat", 'companion' => "Companion app", 'domains' => "Domains", 'files' => "Files", 'invitations' => "Invitations", 'myaccount' => "My account", 'policies' => "Policies", 'profile' => "Your profile", 'resources' => "Resources", 'shared-folders' => "Shared folders", 'users' => "User accounts", 'wallet' => "Wallet", 'webmail' => "Webmail", 'stats' => "Stats", ], 'distlist' => [ 'list-title' => "Distribution list | Distribution lists", 'create' => "Create list", 'delete' => "Delete list", 'email' => "Email", 'list-empty' => "There are no distribution lists in this account.", 'name' => "Name", 'new' => "New distribution list", 'recipients' => "Recipients", 'sender-policy' => "Sender Access List", 'sender-policy-text' => "With this list you can specify who can send mail to the distribution list." . " You can put a complete email address (jane@kolab.org), domain (kolab.org) or suffix (.org) that the sender email address is compared to." . " If the list is empty, mail from anyone is allowed.", ], 'domain' => [ 'config' => "Domain configuration", 'config-intro' => "In order to let {app} receive email traffic for your domain you need to adjust the DNS settings, more precisely the MX entries, accordingly.", 'config-sample' => "Edit your domain's zone file and replace existing MX entries with the following values:", 'config-hint' => "If you don't know how to set DNS entries for your domain, please contact the registration service where you registered the domain or your web hosting provider.", 'confirm' => "Domain ownership confirmation", 'confirm-intro' => "In order to confirm that you're the actual owner or administrator of the domain, " . "we need to run a confirmation process before finally activating it for email delivery.", 'confirm-dns' => "The domain must have one of the following entries in DNS:", 'confirm-dns-txt' => "TXT entry with value:", 'confirm-dns-cname' => "or CNAME entry:", 'confirm-outro' => "Please add one of those records to the DNS of your domain via your domain name provider. " . "When this is done press the button below to start the confirmation.", 'confirm-sample' => "Here's a sample zone file for your domain:", 'create' => "Create domain", 'delete' => "Delete domain", 'delete-domain' => "Delete {domain}", 'delete-text' => "Do you really want to delete this domain permanently?" . " This is only possible if there are no users, aliases or other objects in this domain." . " Please note that this action cannot be undone.", 'dns-confirm' => "Domain DNS confirmation sample:", 'dns-config' => "Domain DNS configuration sample:", 'list-empty' => "There are no domains in this account.", 'namespace' => "Namespace", 'new' => "New domain", 'spf-whitelist' => "SPF Whitelist", 'spf-whitelist-text' => "The Sender Policy Framework allows a sender domain to disclose, through DNS, " . "which systems are allowed to send emails with an envelope sender address within said domain.", 'spf-whitelist-ex' => "Here you can specify a list of allowed servers, for example: .ess.barracuda.com.", ], 'error' => [ '400' => "Bad request", '401' => "Unauthorized", '403' => "Access denied", '404' => "Not found", '405' => "Method not allowed", '500' => "Internal server error", 'unknown' => "Unknown Error", 'server' => "Server Error", 'form' => "Form validation error", ], 'file' => [ 'create' => "Create file", 'delete' => "Delete file", 'drop' => "Click or drop file(s) here", 'list-empty' => "There are no files in this account.", 'mimetype' => "Mimetype", 'mtime' => "Modified", 'new' => "New file", 'search' => "File name", 'sharing' => "Sharing", 'sharing-links-text' => "You can share the file with other users by giving them read-only access " . "to the file via a unique link.", ], - 'collection' => [ - 'create' => "Create collection", - 'new' => "New Collection", - 'name' => "Name", - ], - 'form' => [ 'acl' => "Access rights", 'acl-full' => "All", 'acl-read-only' => "Read-only", 'acl-read-write' => "Read-write", 'amount' => "Amount", 'anyone' => "Anyone", 'code' => "Confirmation Code", 'config' => "Configuration", + 'comment' => "Comment", 'companion' => "Companion App", 'date' => "Date", 'description' => "Description", 'details' => "Details", 'disabled' => "disabled", 'domain' => "Domain", 'email' => "Email Address", 'emails' => "Email Addresses", 'enabled' => "enabled", 'firstname' => "First Name", '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", 'password' => "Password", 'password-confirm' => "Confirm Password", 'personal' => "Personal information", 'phone' => "Phone", 'selectcountries' => "Select countries", 'settings' => "Settings", 'shared-folder' => "Shared Folder", 'size' => "Size", 'status' => "Status", 'subscriptions' => "Subscriptions", 'surname' => "Surname", 'type' => "Type", 'unknown' => "unknown", 'user' => "User", 'primary-email' => "Primary Email", 'id' => "ID", 'created' => "Created", 'deleted' => "Deleted", ], 'invitation' => [ 'create' => "Create invite(s)", 'create-title' => "Invite for a signup", 'create-email' => "Enter an email address of the person you want to invite.", 'create-csv' => "To send multiple invitations at once, provide a CSV (comma separated) file, or alternatively a plain-text file, containing one email address per line.", 'list-empty' => "There are no invitations in the database.", 'title' => "Signup invitations", 'search' => "Email address or domain", 'send' => "Send invite(s)", 'status-completed' => "User signed up", 'status-failed' => "Sending failed", 'status-sent' => "Sent", 'status-new' => "Not sent yet", ], 'lang' => [ 'en' => "English", 'de' => "German", 'fr' => "French", '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.", 'forgot_password' => "Forgot password?", 'header' => "Please sign in", 'sign_in' => "Sign in", 'signing_in' => "Signing in...", 'webmail' => "Webmail" ], 'meet' => [ // Room options dialog 'options' => "Room options", 'password' => "Password", 'password-none' => "none", 'password-clear' => "Clear password", 'password-set' => "Set password", 'password-text' => "You can add a password to your meeting. Participants will have to provide the password before they are allowed to join the meeting.", 'lock' => "Locked room", 'lock-text' => "When the room is locked participants have to be approved by a moderator before they could join the meeting.", 'nomedia' => "Subscribers only", 'nomedia-text' => "Forces all participants to join as subscribers (with camera and microphone turned off)." . " Moderators will be able to promote them to publishers throughout the session.", // Room menu 'partcnt' => "Number of participants", 'menu-audio-mute' => "Mute audio", 'menu-audio-unmute' => "Unmute audio", 'menu-video-mute' => "Mute video", 'menu-video-unmute' => "Unmute video", 'menu-screen' => "Share screen", 'menu-hand-lower' => "Lower hand", 'menu-hand-raise' => "Raise hand", 'menu-channel' => "Interpreted language channel", 'menu-chat' => "Chat", 'menu-fullscreen' => "Full screen", 'menu-fullscreen-exit' => "Exit full screen", 'menu-leave' => "Leave session", // Room setup screen 'setup-title' => "Set up your session", 'mic' => "Microphone", 'cam' => "Camera", 'nick' => "Nickname", 'nick-placeholder' => "Your name", 'join' => "JOIN", 'joinnow' => "JOIN NOW", 'imaowner' => "I'm the owner", // Room 'qa' => "Q & A", 'leave-title' => "Room closed", 'leave-body' => "The session has been closed by the room owner.", 'media-title' => "Media setup", 'join-request' => "Join request", 'join-requested' => "{user} requested to join.", // Status messages 'status-init' => "Checking the room...", 'status-323' => "The room is closed. Please, wait for the owner to start the session.", 'status-324' => "The room is closed. It will be open for others after you join.", 'status-325' => "The room is ready. Please, provide a valid password.", 'status-326' => "The room is locked. Please, enter your name and try again.", 'status-327' => "Waiting for permission to join the room.", 'status-404' => "The room does not exist.", 'status-429' => "Too many requests. Please, wait.", 'status-500' => "Failed to connect to the room. Server error.", // Other menus 'media-setup' => "Media setup", 'perm' => "Permissions", 'perm-av' => "Audio & Video publishing", 'perm-mod' => "Moderation", 'lang-int' => "Language interpreter", 'menu-options' => "Options", ], 'menu' => [ 'cockpit' => "Cockpit", 'login' => "Login", 'logout' => "Logout", 'signup' => "Signup", 'toggle' => "Toggle navigation", ], 'msg' => [ 'initializing' => "Initializing...", 'loading' => "Loading...", 'loading-failed' => "Failed to load data.", 'notfound' => "Resource not found.", 'info' => "Information", 'error' => "Error", 'uploading' => "Uploading...", 'warning' => "Warning", 'success' => "Success", ], 'nav' => [ 'more' => "Load more", 'step' => "Step {i}/{n}", ], 'password' => [ 'link-invalid' => "The password reset code is expired or invalid.", 'reset' => "Password Reset", 'reset-step1' => "Enter your email address to reset your password.", 'reset-step1-hint' => "You may need to check your spam folder or unblock {email}.", 'reset-step2' => "We sent out a confirmation code to your external email address." . " Enter the code we sent you, or click the link in the message.", ], 'policies' => [ 'password-policy' => "Password Policy", 'password-retention' => "Password Retention", 'password-max-age' => "Require a password change every", ], 'resource' => [ 'create' => "Create resource", 'delete' => "Delete resource", 'invitation-policy' => "Invitation policy", 'invitation-policy-text' => "Event invitations for a resource are normally accepted automatically" . " if there is no conflicting event on the requested time slot. Invitation policy allows" . " for rejecting such requests or to require a manual acceptance from a specified user.", 'ipolicy-manual' => "Manual (tentative)", 'ipolicy-accept' => "Accept", 'ipolicy-reject' => "Reject", 'list-title' => "Resource | Resources", 'list-empty' => "There are no resources in this account.", 'new' => "New resource", ], 'room' => [ 'create' => "Create room", 'delete' => "Delete room", 'copy-location' => "Copy room location", 'description-hint' => "This is an optional short description for the room, so you can find it more easily on the list.", 'goto' => "Enter the room", 'list-empty' => "There are no conference rooms in this account.", 'list-empty-nocontroller' => "Do you need a room? Ask your account owner to create one and share it with you.", 'list-title' => "Voice & video conferencing rooms", 'moderators' => "Moderators", 'moderators-text' => "You can share your room with other users. They will become the room moderators with all moderator powers and ability to open the room without your presence.", 'new' => "New room", 'new-hint' => "We'll generate a unique name for the room that will then allow you to access the room.", 'title' => "Room: {name}", 'url' => "You can access the room at the URL below. Use this URL to invite people to join you. This room is only open when you (or another room moderator) is in attendance.", ], 'shf' => [ 'aliases-none' => "This shared folder has no email aliases.", 'create' => "Create folder", 'delete' => "Delete folder", 'acl-text' => "Defines user permissions to access the shared folder.", 'list-title' => "Shared folder | Shared folders", 'list-empty' => "There are no shared folders in this account.", 'new' => "New shared folder", 'type-mail' => "Mail", 'type-event' => "Calendar", 'type-contact' => "Address Book", 'type-task' => "Tasks", 'type-note' => "Notes", 'type-file' => "Files", ], 'signup' => [ 'email' => "Existing Email Address", 'login' => "Login", 'title' => "Sign Up", 'step1' => "Sign up to start your free month.", 'step2' => "We sent out a confirmation code to your email address. Enter the code we sent you, or click the link in the message.", 'step3' => "Create your {app} identity (you can choose additional addresses later).", 'created' => "The account is about to be created!", 'token' => "Signup authorization token", 'voucher' => "Voucher Code", ], 'status' => [ 'prepare-account' => "We are preparing your account.", 'prepare-domain' => "We are preparing the domain.", 'prepare-distlist' => "We are preparing the distribution list.", 'prepare-resource' => "We are preparing the resource.", 'prepare-shared-folder' => "We are preparing the shared folder.", 'prepare-user' => "We are preparing the user account.", 'prepare-hint' => "Some features may be missing or readonly at the moment.", 'prepare-refresh' => "The process never ends? Press the \"Refresh\" button, please.", 'ready-account' => "Your account is almost ready.", 'ready-domain' => "The domain is almost ready.", 'ready-distlist' => "The distribution list is almost ready.", 'ready-resource' => "The resource is almost ready.", 'ready-shared-folder' => "The shared-folder is almost ready.", 'ready-user' => "The user account is almost ready.", 'confirm' => "Confirm your domain to finish the setup process.", 'confirm-domain' => "Confirm domain", 'degraded' => "Degraded", 'deleted' => "Deleted", 'restricted' => "Restricted", 'suspended' => "Suspended", 'notready' => "Not Ready", 'active' => "Active", ], 'support' => [ 'title' => "Contact Support", 'id' => "Customer number or email address you have with us", 'id-pl' => "e.g. 12345678 or the affected email address", 'id-hint' => "Leave blank if you are not a customer yet", 'name' => "Name", 'name-pl' => "how we should call you in our reply", 'email' => "Working email address", 'email-pl' => "make sure we can reach you at this address", 'summary' => "Issue Summary", 'summary-pl' => "one sentence that summarizes your issue", 'expl' => "Issue Explanation", ], 'user' => [ '2fa-hint1' => "This will remove 2-Factor Authentication entitlement as well as the user-configured factors.", '2fa-hint2' => "Please, make sure to confirm the user identity properly.", 'add-beta' => "Enable beta program", 'address' => "Address", 'aliases' => "Aliases", 'aliases-none' => "This user has no email aliases.", 'add-bonus' => "Add bonus", 'add-bonus-title' => "Add a bonus to the wallet", 'add-penalty' => "Add penalty", 'add-penalty-title' => "Add a penalty to the wallet", 'auto-payment' => "Auto-payment", 'auto-payment-text' => "Fill up by {amount} when under {balance} using {method}", 'country' => "Country", 'create' => "Create user", 'custno' => "Customer No.", 'degraded-warning' => "The account is degraded. Some features have been disabled.", 'degraded-hint' => "Please, make a payment.", 'delete' => "Delete user", 'delete-email' => "Delete {email}", 'delete-text' => "Do you really want to delete this user permanently?" . " This will delete all account data and withdraw the permission to access the email account." . " Please note that this action cannot be undone.", 'discount' => "Discount", 'discount-hint' => "applied discount", 'discount-title' => "Account discount", 'distlists' => "Distribution lists", 'domains' => "Domains", 'ext-email' => "External Email", 'email-aliases' => "Email Aliases", 'finances' => "Finances", 'geolimit' => "Geo-lockin", 'geolimit-text' => "Defines a list of locations that are allowed for logon. You will not be able to login from a country that is not listed here.", 'greylisting' => "Greylisting", 'greylisting-text' => "Greylisting is a method of defending users against spam. Any incoming mail from an unrecognized sender " . "is temporarily rejected. The originating server should try again after a delay. " . "This time the email will be accepted. Spammers usually do not reattempt mail delivery.", 'imapproxy' => "IMAP proxy", 'imapproxy-text' => "Enables IMAP proxy that filters out non-mail groupware folders, so your IMAP clients do not see them.", 'list-title' => "User accounts", 'list-empty' => "There are no users in this account.", 'managed-by' => "Managed by", 'new' => "New user account", 'org' => "Organization", 'package' => "Package", 'pass-input' => "Enter password", 'pass-link' => "Set via link", 'pass-link-label' => "Link:", 'pass-link-hint' => "Press Submit to activate the link", 'passwordpolicy' => "Password Policy", 'price' => "Price", 'profile-delete' => "Delete account", 'profile-delete-title' => "Delete this account?", 'profile-delete-text1' => "This will delete the account as well as all domains, users and aliases associated with this account.", 'profile-delete-warning' => "This operation is irreversible", 'profile-delete-text2' => "As you will not be able to recover anything after this point, please make sure that you have migrated all data before proceeding.", 'profile-delete-support' => "As we always strive to improve, we would like to ask for 2 minutes of your time. " . "The best tool for improvement is feedback from users, and we would like to ask " . "for a few words about your reasons for leaving our service. Please send your feedback to {email}.", 'profile-delete-contact' => "Also feel free to contact {app} Support with any questions or concerns that you may have in this context.", 'reset-2fa' => "Reset 2-Factor Auth", 'reset-2fa-title' => "2-Factor Authentication Reset", 'resources' => "Resources", 'title' => "User account", 'search' => "User email address or name", 'search-pl' => "User ID, email or domain", 'skureq' => "{sku} requires {list}.", 'subscription' => "Subscription", 'subscriptions-none' => "This user has no subscriptions.", 'users' => "Users", ], 'wallet' => [ 'add-credit' => "Add credit", 'auto-payment-cancel' => "Cancel auto-payment", 'auto-payment-change' => "Change auto-payment", 'auto-payment-failed' => "The setup of automatic payments failed. Restart the process to enable automatic top-ups.", 'auto-payment-hint' => "Here is how it works: Every time your account runs low, we will charge your preferred payment method for an amount you choose." . " You can cancel or change the auto-payment option at any time.", 'auto-payment-setup' => "Set up auto-payment", 'auto-payment-disabled' => "The configured auto-payment has been disabled. Top up your wallet or raise the auto-payment amount.", 'auto-payment-info' => "Auto-payment is set to fill up your account by {amount} every time your account balance gets under {balance}.", 'auto-payment-inprogress' => "The setup of the automatic payment is still in progress.", 'auto-payment-next' => "Next, you will be redirected to the checkout page, where you can provide your credit card details.", 'auto-payment-disabled-next' => "The auto-payment is disabled. Immediately after you submit new settings we'll enable it and attempt to top up your wallet.", 'auto-payment-update' => "Update auto-payment", 'banktransfer-hint' => "Please note that a bank transfer can take several days to complete.", 'coinbase-hint' => "Here is how it works: You specify the amount by which you want to top up your wallet in {wc}." . " We will then create a charge on Coinbase for the specified amount that you can pay using Bitcoin.", 'currency-conv' => "Here is how it works: You specify the amount by which you want to top up your wallet in {wc}." . " We will then convert this to {pc}, and on the next page you will be provided with the bank-details to transfer the amount in {pc}.", 'fill-up' => "Fill up by", 'history' => "History", 'locked-text' => "The account is locked until you set up auto-payment successfully.", 'month' => "month", 'noperm' => "Only account owners can access a wallet.", 'norefund' => "The money in your wallet is non-refundable.", 'payment-amount-hint' => "Choose the amount by which you want to top up your wallet.", 'payment-method' => "Method of payment: {method}", 'payment-warning' => "You will be charged for {price}.", 'pending-payments' => "Pending Payments", 'pending-payments-warning' => "You have payments that are still in progress. See the \"Pending Payments\" tab below.", 'pending-payments-none' => "There are no pending payments for this account.", 'receipts' => "Receipts", 'receipts-hint' => "Here you can download receipts (in PDF format) for payments in specified period. Select the period and press the Download button.", 'receipts-none' => "There are no receipts for payments in this account. Please, note that you can download receipts after the month ends.", 'title' => "Account balance", 'top-up' => "Top up your wallet", 'transactions' => "Transactions", 'transactions-none' => "There are no transactions for this account.", 'when-below' => "when account balance is below", ], ]; diff --git a/src/resources/themes/app.scss b/src/resources/themes/app.scss index fc2b481c..bee23a26 100644 --- a/src/resources/themes/app.scss +++ b/src/resources/themes/app.scss @@ -1,580 +1,605 @@ html, body, body > .outer-container { height: 100%; } #app { display: flex; flex-direction: column; min-height: 100%; overflow: hidden; & > nav { flex-shrink: 0; z-index: 12; } & > div.container { flex-grow: 1; margin-top: 2rem; margin-bottom: 2rem; } & > .filler { flex-grow: 1; } & > div.container + .filler { display: none; } } .error-page { position: absolute; top: 0; height: 100%; width: 100%; align-content: center; align-items: center; display: flex; flex-wrap: wrap; justify-content: center; color: #636b6f; z-index: 10; background: white; .code { text-align: right; border-right: 2px solid; font-size: 26px; padding: 0 15px; } .message { font-size: 18px; padding: 0 15px; } .hint { margin-top: 3em; text-align: center; width: 100%; } } .app-loader { background-color: $body-bg; height: 100%; width: 100%; position: absolute; top: 0; left: 0; display: flex; align-items: center; justify-content: center; z-index: 8; .spinner-border { width: 120px; height: 120px; border-width: 15px; color: #b2aa99; } &.small .spinner-border { width: 25px; height: 25px; border-width: 3px; } &.fadeOut { visibility: hidden; opacity: 0; transition: visibility 300ms linear, opacity 300ms linear; } } pre { margin: 1rem 0; padding: 1rem; background-color: $menu-bg-color; } .card-title { font-size: 1.2rem; font-weight: bold; } tfoot.table-fake-body { background-color: #f8f8f8; color: grey; text-align: center; td { vertical-align: middle; height: 8em; border: 0; } tbody:not(:empty) + & { display: none; } } table { th { white-space: nowrap; } td .btn-link { vertical-align: initial; } td.email, td.price, td.datetime, td.selection { width: 1%; white-space: nowrap; } td.buttons, th.price, td.price, th.size, td.size { width: 1%; text-align: right; white-space: nowrap; } &.form-list { margin: 0; td { border: 0; &:first-child { padding-left: 0; } &:last-child { padding-right: 0; } } button { line-height: 1; } } .btn-action { line-height: 1; padding: 0; } &.files { table-layout: fixed; td { white-space: nowrap; } td.name { overflow: hidden; text-overflow: ellipsis; } /* td.size, th.size { width: 80px; } td.mtime, th.mtime { width: 140px; @include media-breakpoint-down(sm) { display: none; } } */ td.buttons, th.buttons { 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) { // Remove Bootstrap's 2px border border-width: 0; } .list-details { min-height: 1em; & > ul { margin: 0; padding-left: 1.2em; } } .plan-selector { .plan-header { display: flex; } .plan-ico { margin:auto; font-size: 3.8rem; color: $main-color; border: 3px solid $main-color; width: 6rem; height: 6rem; border-radius: 50%; } } .status-message { display: flex; align-items: center; justify-content: center; .app-loader { width: auto; position: initial; .spinner-border { color: $body-color; } } svg { font-size: 1.5em; } :first-child { margin-right: 0.4em; } } .form-separator { position: relative; margin: 1em 0; display: flex; justify-content: center; hr { border-color: #999; margin: 0; position: absolute; top: 0.75em; width: 100%; } span { background: #fff; padding: 0 1em; z-index: 1; } } .modal { .modal-dialog, .modal-content { max-height: calc(100vh - 3.5rem); } .modal-body { overflow: auto !important; } &.fullscreen { .modal-dialog { height: 100%; width: 100%; max-width: calc(100vw - 1rem); } .modal-content { height: 100%; max-height: 100% !important; } .modal-body { padding: 0; margin: 1em; overflow: hidden !important; } } } .credit-cards { img { width: 4em; height: 2.8em; padding: 0.4em; border: 1px solid lightgrey; border-radius: 0.4em; margin-right: 0.5em; } } #status-box { background-color: lighten($green, 35); .progress { background-color: #fff; height: 10px; } .progress-label { font-size: 0.9em; } .progress-bar { background-color: $green; } &.process-failed { background-color: lighten($orange, 30); .progress-bar { background-color: $red; } } } @keyframes blinker { 50% { opacity: 0; } } .blinker { animation: blinker 750ms step-start infinite; } #dashboard-nav { display: flex; flex-wrap: wrap; justify-content: center; & > a { padding: 1rem; text-align: center; white-space: nowrap; margin: 0.25rem; text-decoration: none; width: 150px; &.disabled { pointer-events: none; opacity: 0.6; } // Some icons are too big, scale them down &.link-companionapp, &.link-domains, &.link-policies, &.link-resources, &.link-wallet, &.link-invitations { svg { transform: scale(0.8); } } &.link-distlists, &.link-files, &.link-settings, &.link-shared-folders { svg { transform: scale(0.9); } } .badge { position: absolute; top: 0.5rem; right: 0.5rem; } } svg { width: 6rem; height: 6rem; margin: auto; } } #payment-method-selection { display: flex; flex-wrap: wrap; justify-content: center; & > a { padding: 1rem; text-align: center; white-space: nowrap; margin: 0.25rem; text-decoration: none; width: 150px; } svg { width: 6rem; height: 6rem; margin: auto; } .link-banktransfer svg { transform: scale(.8); } } #summary-summary { padding: 0.5rem; table { width: 100%; } tr { &.total { font-weight: bold; } &.vat-summary { font-size: small; } } td { padding: 0.25em; &.money { white-space: nowrap; text-align: right; } } } #logon-form { flex-basis: auto; // Bootstrap issue? See logon page with width < 992 } #logon-form-footer { a:not(:first-child) { margin-left: 2em; } } // Various improvements for mobile @include media-breakpoint-down(sm) { .card, .card-footer { border: 0; } .card-body { padding: 0.5rem 0; } .nav-tabs { flex-wrap: nowrap; .nav-link { white-space: nowrap; padding: 0.5rem 0.75rem; } } #app > div.container { margin-bottom: 1rem; margin-top: 1rem; max-width: 100%; } #header-menu-navbar { padding: 0; } #dashboard-nav > a { width: 135px; } .table-sm:not(.form-list) { tbody td { padding: 0.75rem 0.5rem; svg { vertical-align: -0.175em; } & > svg { font-size: 125%; margin-right: 0.25rem; } } } .table.transactions { thead { display: none; } tbody { tr { position: relative; display: flex; flex-wrap: wrap; } td { width: auto; border: 0; padding: 0.5rem; &.datetime { width: 50%; padding-left: 0; } &.description { order: 3; width: 100%; border-bottom: 1px solid $border-color; color: $secondary; padding: 0 1.5em 0.5rem 0; margin-top: -0.25em; } &.selection { position: absolute; right: 0; border: 0; top: 1.7em; padding-right: 0; } &.price { width: 50%; padding-right: 0; } &.email { display: none; } } } } } @include media-breakpoint-down(sm) { .tab-pane > .card-body { padding: 0.5rem; } } diff --git a/src/resources/vue/Admin/Distlist.vue b/src/resources/vue/Admin/Distlist.vue index f7721947..61d4d40e 100644 --- a/src/resources/vue/Admin/Distlist.vue +++ b/src/resources/vue/Admin/Distlist.vue @@ -1,107 +1,134 @@ diff --git a/src/resources/vue/Admin/Domain.vue b/src/resources/vue/Admin/Domain.vue index 6ac4f19a..a29f9806 100644 --- a/src/resources/vue/Admin/Domain.vue +++ b/src/resources/vue/Admin/Domain.vue @@ -1,107 +1,134 @@ diff --git a/src/resources/vue/Admin/User.vue b/src/resources/vue/Admin/User.vue index 79cd84f2..1f80cbc3 100644 --- a/src/resources/vue/Admin/User.vue +++ b/src/resources/vue/Admin/User.vue @@ -1,646 +1,674 @@ diff --git a/src/resources/vue/Widgets/EventLog.vue b/src/resources/vue/Widgets/EventLog.vue new file mode 100644 index 00000000..6d166e71 --- /dev/null +++ b/src/resources/vue/Widgets/EventLog.vue @@ -0,0 +1,69 @@ + + + diff --git a/src/resources/vue/Widgets/ListTools.vue b/src/resources/vue/Widgets/ListTools.vue index c9d295c1..a08635ef 100644 --- a/src/resources/vue/Widgets/ListTools.vue +++ b/src/resources/vue/Widgets/ListTools.vue @@ -1,162 +1,161 @@ diff --git a/src/resources/vue/Widgets/ModalDialog.vue b/src/resources/vue/Widgets/ModalDialog.vue index a0a351ee..f037c85c 100644 --- a/src/resources/vue/Widgets/ModalDialog.vue +++ b/src/resources/vue/Widgets/ModalDialog.vue @@ -1,95 +1,95 @@ diff --git a/src/routes/api.php b/src/routes/api.php index 65e3d4ed..b5e5d2c7 100644 --- a/src/routes/api.php +++ b/src/routes/api.php @@ -1,317 +1,321 @@ 'api', 'prefix' => 'auth' ], function () { Route::post('login', [API\AuthController::class, 'login']); Route::group( ['middleware' => 'auth:api'], function () { Route::get('info', [API\AuthController::class, 'info']); Route::post('info', [API\AuthController::class, 'info']); Route::get('location', [API\AuthController::class, 'location']); Route::post('logout', [API\AuthController::class, 'logout']); Route::post('refresh', [API\AuthController::class, 'refresh']); } ); } ); Route::group( [ 'domain' => \config('app.website_domain'), 'middleware' => 'api', 'prefix' => 'auth' ], function () { Route::post('password-policy/check', [API\PasswordPolicyController::class, 'check']); Route::post('password-reset/init', [API\PasswordResetController::class, 'init']); Route::post('password-reset/verify', [API\PasswordResetController::class, 'verify']); Route::post('password-reset', [API\PasswordResetController::class, 'reset']); } ); if (\config('app.with_signup')) { Route::group( [ 'domain' => \config('app.website_domain'), 'middleware' => 'api', 'prefix' => 'auth' ], function () { Route::get('signup/domains', [API\SignupController::class, 'domains']); Route::post('signup/init', [API\SignupController::class, 'init']); Route::get('signup/invitations/{id}', [API\SignupController::class, 'invitation']); Route::get('signup/plans', [API\SignupController::class, 'plans']); Route::post('signup/validate', [API\SignupController::class, 'signupValidate']); Route::post('signup/verify', [API\SignupController::class, 'verify']); Route::post('signup', [API\SignupController::class, 'signup']); } ); } Route::group( [ 'domain' => \config('app.website_domain'), 'middleware' => ['auth:api', 'scope:mfa,api'], 'prefix' => 'v4' ], function () { Route::post('auth-attempts/{id}/confirm', [API\V4\AuthAttemptsController::class, 'confirm']); Route::post('auth-attempts/{id}/deny', [API\V4\AuthAttemptsController::class, 'deny']); Route::get('auth-attempts/{id}/details', [API\V4\AuthAttemptsController::class, 'details']); Route::get('auth-attempts', [API\V4\AuthAttemptsController::class, 'index']); Route::post('companion/register', [API\V4\CompanionAppsController::class, 'register']); } ); if (\config('app.with_files')) { Route::group( [ 'domain' => \config('app.website_domain'), 'middleware' => ['auth:api', 'scope:fs,api'], 'prefix' => 'v4' ], function () { Route::apiResource('fs', API\V4\FsController::class); Route::get('fs/{itemId}/permissions', [API\V4\FsController::class, 'getPermissions']); Route::post('fs/{itemId}/permissions', [API\V4\FsController::class, 'createPermission']); Route::put('fs/{itemId}/permissions/{id}', [API\V4\FsController::class, 'updatePermission']); Route::delete('fs/{itemId}/permissions/{id}', [API\V4\FsController::class, 'deletePermission']); } ); Route::group( [ 'domain' => \config('app.website_domain'), 'middleware' => [], 'prefix' => 'v4' ], function () { Route::post('fs/uploads/{id}', [API\V4\FsController::class, 'upload']) ->middleware(['api']); Route::get('fs/downloads/{id}', [API\V4\FsController::class, 'download']); } ); } Route::group( [ 'domain' => \config('app.website_domain'), 'middleware' => ['auth:api', 'scope:api'], 'prefix' => 'v4' ], function () { Route::apiResource('companions', API\V4\CompanionAppsController::class); // This must not be accessible with the 2fa token, // to prevent an attacker from pairing a new device with a stolen token. Route::get('companions/{id}/pairing', [API\V4\CompanionAppsController::class, 'pairing']); Route::apiResource('domains', API\V4\DomainsController::class); Route::get('domains/{id}/confirm', [API\V4\DomainsController::class, 'confirm']); Route::get('domains/{id}/skus', [API\V4\DomainsController::class, 'skus']); Route::get('domains/{id}/status', [API\V4\DomainsController::class, 'status']); Route::post('domains/{id}/config', [API\V4\DomainsController::class, 'setConfig']); Route::apiResource('groups', API\V4\GroupsController::class); Route::get('groups/{id}/skus', [API\V4\GroupsController::class, 'skus']); Route::get('groups/{id}/status', [API\V4\GroupsController::class, 'status']); Route::post('groups/{id}/config', [API\V4\GroupsController::class, 'setConfig']); Route::apiResource('packages', API\V4\PackagesController::class); Route::apiResource('rooms', API\V4\RoomsController::class); Route::post('rooms/{id}/config', [API\V4\RoomsController::class, 'setConfig']); Route::get('rooms/{id}/skus', [API\V4\RoomsController::class, 'skus']); Route::post('meet/rooms/{id}', [API\V4\MeetController::class, 'joinRoom']) ->withoutMiddleware(['auth:api', 'scope:api']); Route::apiResource('resources', API\V4\ResourcesController::class); Route::get('resources/{id}/skus', [API\V4\ResourcesController::class, 'skus']); Route::get('resources/{id}/status', [API\V4\ResourcesController::class, 'status']); Route::post('resources/{id}/config', [API\V4\ResourcesController::class, 'setConfig']); Route::apiResource('shared-folders', API\V4\SharedFoldersController::class); Route::get('shared-folders/{id}/skus', [API\V4\SharedFoldersController::class, 'skus']); Route::get('shared-folders/{id}/status', [API\V4\SharedFoldersController::class, 'status']); Route::post('shared-folders/{id}/config', [API\V4\SharedFoldersController::class, 'setConfig']); Route::apiResource('skus', API\V4\SkusController::class); Route::apiResource('users', API\V4\UsersController::class); Route::post('users/{id}/config', [API\V4\UsersController::class, 'setConfig']); Route::get('users/{id}/skus', [API\V4\UsersController::class, 'skus']); Route::get('users/{id}/status', [API\V4\UsersController::class, 'status']); Route::apiResource('wallets', API\V4\WalletsController::class); Route::get('wallets/{id}/transactions', [API\V4\WalletsController::class, 'transactions']); Route::get('wallets/{id}/receipts', [API\V4\WalletsController::class, 'receipts']); Route::get('wallets/{id}/receipts/{receipt}', [API\V4\WalletsController::class, 'receiptDownload']); Route::get('password-policy', [API\PasswordPolicyController::class, 'index']); Route::post('password-reset/code', [API\PasswordResetController::class, 'codeCreate']); Route::delete('password-reset/code/{id}', [API\PasswordResetController::class, 'codeDelete']); Route::post('payments', [API\V4\PaymentsController::class, 'store']); //Route::delete('payments', [API\V4\PaymentsController::class, 'cancel']); Route::get('payments/mandate', [API\V4\PaymentsController::class, 'mandate']); Route::post('payments/mandate', [API\V4\PaymentsController::class, 'mandateCreate']); Route::put('payments/mandate', [API\V4\PaymentsController::class, 'mandateUpdate']); Route::delete('payments/mandate', [API\V4\PaymentsController::class, 'mandateDelete']); Route::post('payments/mandate/reset', [API\V4\PaymentsController::class, 'mandateReset']); Route::get('payments/methods', [API\V4\PaymentsController::class, 'paymentMethods']); Route::get('payments/pending', [API\V4\PaymentsController::class, 'payments']); Route::get('payments/has-pending', [API\V4\PaymentsController::class, 'hasPayments']); Route::get('payments/status', [API\V4\PaymentsController::class, 'paymentStatus']); Route::post('support/request', [API\V4\SupportController::class, 'request']) ->withoutMiddleware(['auth:api', 'scope:api']) ->middleware(['api']); Route::get('vpn/token', [API\V4\VPNController::class, 'token']); } ); Route::group( [ 'domain' => \config('app.website_domain'), 'prefix' => 'webhooks' ], function () { Route::post('payment/{provider}', [API\V4\PaymentsController::class, 'webhook']); Route::post('meet', [API\V4\MeetController::class, 'webhook']); } ); if (\config('app.with_services')) { Route::group( [ 'domain' => \config('app.services_domain'), 'prefix' => 'webhooks' ], function () { Route::get('nginx', [API\V4\NGINXController::class, 'authenticate']); Route::get('nginx-roundcube', [API\V4\NGINXController::class, 'authenticateRoundcube']); Route::get('nginx-httpauth', [API\V4\NGINXController::class, 'httpauth']); Route::post('cyrus-sasl', [API\V4\NGINXController::class, 'cyrussasl']); Route::post('policy/greylist', [API\V4\PolicyController::class, 'greylist']); Route::post('policy/ratelimit', [API\V4\PolicyController::class, 'ratelimit']); Route::post('policy/spf', [API\V4\PolicyController::class, 'senderPolicyFramework']); } ); } if (\config('app.with_admin')) { Route::group( [ 'domain' => 'admin.' . \config('app.website_domain'), 'middleware' => ['auth:api', 'admin'], 'prefix' => 'v4', ], function () { Route::apiResource('domains', API\V4\Admin\DomainsController::class); Route::get('domains/{id}/skus', [API\V4\Admin\DomainsController::class, 'skus']); 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']); Route::apiResource('resources', API\V4\Admin\ResourcesController::class); Route::apiResource('shared-folders', API\V4\Admin\SharedFoldersController::class); Route::apiResource('skus', API\V4\Admin\SkusController::class); Route::apiResource('users', API\V4\Admin\UsersController::class); Route::get('users/{id}/discounts', [API\V4\Admin\DiscountsController::class, 'userDiscounts']); Route::post('users/{id}/reset2FA', [API\V4\Admin\UsersController::class, 'reset2FA']); Route::post('users/{id}/resetGeoLock', [API\V4\Admin\UsersController::class, 'resetGeoLock']); Route::post('users/{id}/resync', [API\V4\Admin\UsersController::class, 'resync']); Route::get('users/{id}/skus', [API\V4\Admin\UsersController::class, 'skus']); Route::post('users/{id}/skus/{sku}', [API\V4\Admin\UsersController::class, 'setSku']); Route::post('users/{id}/suspend', [API\V4\Admin\UsersController::class, 'suspend']); Route::post('users/{id}/unsuspend', [API\V4\Admin\UsersController::class, 'unsuspend']); Route::apiResource('wallets', API\V4\Admin\WalletsController::class); Route::post('wallets/{id}/one-off', [API\V4\Admin\WalletsController::class, 'oneOff']); Route::get('wallets/{id}/transactions', [API\V4\Admin\WalletsController::class, 'transactions']); Route::get('stats/chart/{chart}', [API\V4\Admin\StatsController::class, 'chart']); } ); } if (\config('app.with_reseller')) { Route::group( [ 'domain' => 'reseller.' . \config('app.website_domain'), 'middleware' => ['auth:api', 'reseller'], 'prefix' => 'v4', ], function () { Route::apiResource('domains', API\V4\Reseller\DomainsController::class); Route::get('domains/{id}/skus', [API\V4\Reseller\DomainsController::class, 'skus']); 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']); Route::apiResource('invitations', API\V4\Reseller\InvitationsController::class); Route::post('invitations/{id}/resend', [API\V4\Reseller\InvitationsController::class, 'resend']); Route::post('payments', [API\V4\Reseller\PaymentsController::class, 'store']); Route::get('payments/mandate', [API\V4\Reseller\PaymentsController::class, 'mandate']); Route::post('payments/mandate', [API\V4\Reseller\PaymentsController::class, 'mandateCreate']); Route::put('payments/mandate', [API\V4\Reseller\PaymentsController::class, 'mandateUpdate']); Route::delete('payments/mandate', [API\V4\Reseller\PaymentsController::class, 'mandateDelete']); Route::get('payments/methods', [API\V4\Reseller\PaymentsController::class, 'paymentMethods']); Route::get('payments/pending', [API\V4\Reseller\PaymentsController::class, 'payments']); Route::get('payments/has-pending', [API\V4\Reseller\PaymentsController::class, 'hasPayments']); Route::apiResource('resources', API\V4\Reseller\ResourcesController::class); Route::apiResource('shared-folders', API\V4\Reseller\SharedFoldersController::class); Route::apiResource('skus', API\V4\Reseller\SkusController::class); Route::apiResource('users', API\V4\Reseller\UsersController::class); Route::get('users/{id}/discounts', [API\V4\Reseller\DiscountsController::class, 'userDiscounts']); Route::post('users/{id}/reset2FA', [API\V4\Reseller\UsersController::class, 'reset2FA']); Route::post('users/{id}/resetGeoLock', [API\V4\Reseller\UsersController::class, 'resetGeoLock']); Route::post('users/{id}/resync', [API\V4\Reseller\UsersController::class, 'resync']); Route::get('users/{id}/skus', [API\V4\Reseller\UsersController::class, 'skus']); Route::post('users/{id}/skus/{sku}', [API\V4\Reseller\UsersController::class, 'setSku']); Route::post('users/{id}/suspend', [API\V4\Reseller\UsersController::class, 'suspend']); Route::post('users/{id}/unsuspend', [API\V4\Reseller\UsersController::class, 'unsuspend']); Route::apiResource('wallets', API\V4\Reseller\WalletsController::class); Route::post('wallets/{id}/one-off', [API\V4\Reseller\WalletsController::class, 'oneOff']); Route::get('wallets/{id}/receipts', [API\V4\Reseller\WalletsController::class, 'receipts']); Route::get('wallets/{id}/receipts/{receipt}', [API\V4\Reseller\WalletsController::class, 'receiptDownload']); Route::get('wallets/{id}/transactions', [API\V4\Reseller\WalletsController::class, 'transactions']); Route::get('stats/chart/{chart}', [API\V4\Reseller\StatsController::class, 'chart']); } ); } diff --git a/src/tests/Browser/Admin/DistlistTest.php b/src/tests/Browser/Admin/DistlistTest.php index 52163718..a368ee34 100644 --- a/src/tests/Browser/Admin/DistlistTest.php +++ b/src/tests/Browser/Admin/DistlistTest.php @@ -1,138 +1,199 @@ deleteTestGroup('group-test@kolab.org'); + Eventlog::query()->delete(); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestGroup('group-test@kolab.org'); + Eventlog::query()->delete(); parent::tearDown(); } /** * Test distlist info page (unauthenticated) */ public function testDistlistUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $user = $this->getTestUser('john@kolab.org'); $group = $this->getTestGroup('group-test@kolab.org'); $group->assignToWallet($user->wallets->first()); $browser->visit('/distlist/' . $group->id)->on(new Home()); }); } /** * Test distribution list info page */ public function testInfo(): void { Queue::fake(); $this->browse(function (Browser $browser) { $user = $this->getTestUser('john@kolab.org'); $group = $this->getTestGroup('group-test@kolab.org', ['name' => 'Test Group']); $group->assignToWallet($user->wallets->first()); $group->members = ['test1@gmail.com', 'test2@gmail.com']; $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); // Goto the distlist page $browser->visit(new Home()) ->submitLogon('jeroen@jeroen.jeroen', \App\Utils::generatePassphrase(), true) ->on(new Dashboard()) ->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) ->with('@distlist-info form', function (Browser $browser) use ($group) { $browser->assertElementsCount('.row', 4) ->assertSeeIn('.row:nth-child(1) label', 'ID (Created)') ->assertSeeIn('.row:nth-child(1) #distlistid', "{$group->id} ({$group->created_at})") ->assertSeeIn('.row:nth-child(2) label', 'Status') ->assertSeeIn('.row:nth-child(2) #status.text-danger', 'Not Ready') ->assertSeeIn('.row:nth-child(3) label', 'Name') ->assertSeeIn('.row:nth-child(3) #name', $group->name) ->assertSeeIn('.row:nth-child(4) label', 'Recipients') ->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); }); } /** * Test suspending/unsuspending a distribution list * * @depends testInfo */ public function testSuspendAndUnsuspend(): void { Queue::fake(); + Eventlog::query()->delete(); $this->browse(function (Browser $browser) { $user = $this->getTestUser('john@kolab.org'); $group = $this->getTestGroup('group-test@kolab.org'); $group->assignToWallet($user->wallets->first()); $group->status = Group::STATUS_ACTIVE | Group::STATUS_LDAP_READY; $group->save(); $browser->visit(new DistlistPage($group->id)) ->assertVisible('@distlist-info #button-suspend') ->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 index b81a7a2d..90498efd 100644 --- a/src/tests/Browser/Admin/DomainTest.php +++ b/src/tests/Browser/Admin/DomainTest.php @@ -1,159 +1,224 @@ deleteTestUser('test1@domainscontroller.com'); $this->deleteTestDomain('domainscontroller.com'); + Eventlog::query()->delete(); + self::useAdminUrl(); } /** * {@inheritDoc} */ public function tearDown(): void { $domain = $this->getTestDomain('kolab.org'); $domain->setSetting('spf_whitelist', null); $this->deleteTestUser('test1@domainscontroller.com'); $this->deleteTestDomain('domainscontroller.com'); + Eventlog::query()->delete(); + parent::tearDown(); } /** * Test domain info page (unauthenticated) */ public function testDomainUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $domain = $this->getTestDomain('kolab.org'); $browser->visit('/domain/' . $domain->id)->on(new Home()); }); } /** * Test domain info page */ public function testDomainInfo(): void { $this->browse(function (Browser $browser) { $domain = $this->getTestDomain('kolab.org'); $domain_page = new DomainPage($domain->id); $john = $this->getTestUser('john@kolab.org'); $user_page = new UserPage($john->id); $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) ->on(new Dashboard()) ->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) ->assertSeeIn('@domain-info .card-title', 'kolab.org') ->with('@domain-info form', function (Browser $browser) use ($domain) { $browser->assertElementsCount('.row', 2) ->assertSeeIn('.row:nth-child(1) label', 'ID (Created)') ->assertSeeIn('.row:nth-child(1) #domainid', "{$domain->id} ({$domain->created_at})") ->assertSeeIn('.row:nth-child(2) label', 'Status') ->assertSeeIn('.row:nth-child(2) #status span.text-success', 'Active'); }); // 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') ->with('@domain-config', function (Browser $browser) { $browser->assertSeeIn('pre#dns-confirm', 'kolab-verify.kolab.org.') ->assertSeeIn('pre#dns-config', 'kolab.org.'); }); // Assert Settings tab $browser->assertSeeIn('@nav #tab-settings', 'Settings') ->click('@nav #tab-settings') ->with('@domain-settings form', function (Browser $browser) { $browser->assertElementsCount('.row', 1) ->assertSeeIn('.row:first-child label', 'SPF Whitelist') ->assertSeeIn('.row:first-child .form-control-plaintext', 'none'); }); // Assert non-empty SPF whitelist $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'); + }); }); } /** * Test suspending/unsuspending a domain * * @depends testDomainInfo */ 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'); $domain = $this->getTestDomain('domainscontroller.com', [ 'status' => Domain::STATUS_NEW | Domain::STATUS_ACTIVE | Domain::STATUS_LDAP_READY | Domain::STATUS_CONFIRMED | Domain::STATUS_VERIFIED, 'type' => Domain::TYPE_EXTERNAL, ]); \App\Entitlement::create([ 'wallet_id' => $user->wallets()->first()->id, 'sku_id' => $sku_domain->id, 'entitleable_id' => $domain->id, 'entitleable_type' => Domain::class ]); $browser->visit(new DomainPage($domain->id)) ->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 index 1552b73b..91314627 100644 --- a/src/tests/Browser/Admin/UserTest.php +++ b/src/tests/Browser/Admin/UserTest.php @@ -1,653 +1,724 @@ getTestUser('john@kolab.org'); $john->setSettings([ 'phone' => '+48123123123', 'external_email' => 'john.doe.external@gmail.com', ]); if ($john->isSuspended()) { User::where('email', $john->email)->update(['status' => $john->status - User::STATUS_SUSPENDED]); } $wallet = $john->wallets()->first(); $wallet->discount()->dissociate(); $wallet->save(); Entitlement::where('cost', '>=', 5000)->delete(); + Eventlog::query()->delete(); + $this->deleteTestGroup('group-test@kolab.org'); $this->deleteTestUser('userstest1@kolabnow.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $john = $this->getTestUser('john@kolab.org'); $john->setSettings([ 'phone' => null, 'external_email' => 'john.doe.external@gmail.com', ]); if ($john->isSuspended()) { User::where('email', $john->email)->update(['status' => $john->status - User::STATUS_SUSPENDED]); } $wallet = $john->wallets()->first(); $wallet->discount()->dissociate(); $wallet->save(); Entitlement::where('cost', '>=', 5000)->delete(); + Eventlog::query()->delete(); + $this->deleteTestGroup('group-test@kolab.org'); $this->deleteTestUser('userstest1@kolabnow.com'); parent::tearDown(); } /** * Test user info page (unauthenticated) */ public function testUserUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $jack = $this->getTestUser('jack@kolab.org'); $browser->visit('/user/' . $jack->id)->on(new Home()); }); } /** * Test user info page */ public function testUserInfo(): void { $this->browse(function (Browser $browser) { $jack = $this->getTestUser('jack@kolab.org'); $jack->setSettings([ 'limit_geo' => null, 'organization' => null, '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()) ->submitLogon('jeroen@jeroen.jeroen', \App\Utils::generatePassphrase(), true) ->on(new Dashboard()) ->visit($page) ->on($page); // Assert main info box content $browser->assertSeeIn('@user-info .card-title', $jack->email) ->with('@user-info form', function (Browser $browser) use ($jack) { $browser->assertElementsCount('.row', 7) ->assertSeeIn('.row:nth-child(1) label', 'Managed by') ->assertSeeIn('.row:nth-child(1) #manager a', 'john@kolab.org') ->assertSeeIn('.row:nth-child(2) label', 'ID (Created)') ->assertSeeIn('.row:nth-child(2) #userid', "{$jack->id} ({$jack->created_at})") ->assertSeeIn('.row:nth-child(3) label', 'Status') ->assertSeeIn('.row:nth-child(3) #status span.text-success', 'Active') ->assertDontSeeIn('.row:nth-child(3) #status', 'Restricted') ->assertSeeIn('.row:nth-child(4) label', 'First Name') ->assertSeeIn('.row:nth-child(4) #first_name', 'Jack') ->assertSeeIn('.row:nth-child(5) label', 'Last Name') ->assertSeeIn('.row:nth-child(5) #last_name', 'Daniels') ->assertSeeIn('.row:nth-child(6) label', 'External Email') ->assertMissing('.row:nth-child(6) #external_email a') ->assertSeeIn('.row:nth-child(7) label', 'Country') ->assertSeeIn('.row:nth-child(7) #country', 'United States'); }); // 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'); // Assert Aliases tab $browser->assertSeeIn('@nav #tab-aliases', 'Aliases (1)') ->click('@nav #tab-aliases') ->whenAvailable('@user-aliases', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 1) ->assertSeeIn('table tbody tr:first-child td:first-child', 'jack.daniels@kolab.org') ->assertMissing('table tfoot'); }); // Assert Subscriptions tab $browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (3)') ->click('@nav #tab-subscriptions') ->with('@user-subscriptions', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 3) ->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox') ->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '5,00 CHF') ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 5 GB') ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF') ->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features') ->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,90 CHF') ->assertMissing('table tfoot') ->assertMissing('#reset2fa'); }); // Assert Domains tab $browser->assertSeeIn('@nav #tab-domains', 'Domains (0)') ->click('@nav #tab-domains') ->with('@user-domains', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no domains in this account.'); }); // Assert Users tab $browser->assertSeeIn('@nav #tab-users', 'Users (0)') ->click('@nav #tab-users') ->with('@user-users', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no users in this account.'); }); // Assert Distribution lists tab $browser->assertSeeIn('@nav #tab-distlists', 'Distribution lists (0)') ->click('@nav #tab-distlists') ->with('@user-distlists', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no distribution lists in this account.'); }); // Assert Resources tab $browser->assertSeeIn('@nav #tab-resources', 'Resources (0)') ->click('@nav #tab-resources') ->with('@user-resources', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no resources in this account.'); }); // Assert Shared folders tab $browser->assertSeeIn('@nav #tab-folders', 'Shared folders (0)') ->click('@nav #tab-folders') ->with('@user-folders', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no shared folders in this account.'); }); // Assert Settings tab $browser->assertSeeIn('@nav #tab-settings', 'Settings') ->click('@nav #tab-settings') ->whenAvailable('@user-settings form', function (Browser $browser) { $browser->assertElementsCount('.row', 3) ->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') ->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'); + }); }); } /** * Test user info page (continue) * * @depends testUserInfo */ public function testUserInfo2(): void { $this->browse(function (Browser $browser) { $john = $this->getTestUser('john@kolab.org'); $page = new UserPage($john->id); $discount = Discount::where('code', 'TEST')->first(); $wallet = $john->wallet(); $wallet->discount()->associate($discount); $wallet->debit(2010); $wallet->save(); $group = $this->getTestGroup('group-test@kolab.org', ['name' => 'Test Group']); $group->assignToWallet($john->wallets->first()); $john->setSetting('greylist_enabled', null); // Click the managed-by link on Jack's page $browser->click('@user-info #manager a') ->on($page); // Assert main info box content $browser->assertSeeIn('@user-info .card-title', $john->email) ->with('@user-info form', function (Browser $browser) use ($john) { $ext_email = $john->getSetting('external_email'); $browser->assertElementsCount('.row', 9) ->assertSeeIn('.row:nth-child(1) label', 'ID (Created)') ->assertSeeIn('.row:nth-child(1) #userid', "{$john->id} ({$john->created_at})") ->assertSeeIn('.row:nth-child(2) label', 'Status') ->assertSeeIn('.row:nth-child(2) #status span.text-success', 'Active') ->assertSeeIn('.row:nth-child(3) label', 'First Name') ->assertSeeIn('.row:nth-child(3) #first_name', 'John') ->assertSeeIn('.row:nth-child(4) label', 'Last Name') ->assertSeeIn('.row:nth-child(4) #last_name', 'Doe') ->assertSeeIn('.row:nth-child(5) label', 'Organization') ->assertSeeIn('.row:nth-child(5) #organization', 'Kolab Developers') ->assertSeeIn('.row:nth-child(6) label', 'Phone') ->assertSeeIn('.row:nth-child(6) #phone', $john->getSetting('phone')) ->assertSeeIn('.row:nth-child(7) label', 'External Email') ->assertSeeIn('.row:nth-child(7) #external_email a', $ext_email) ->assertAttribute('.row:nth-child(7) #external_email a', 'href', "mailto:$ext_email") ->assertSeeIn('.row:nth-child(8) label', 'Address') ->assertSeeIn('.row:nth-child(8) #billing_address', $john->getSetting('billing_address')) ->assertSeeIn('.row:nth-child(9) label', 'Country') ->assertSeeIn('.row:nth-child(9) #country', 'United States'); }); // 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'); // Assert Aliases tab $browser->assertSeeIn('@nav #tab-aliases', 'Aliases (1)') ->click('@nav #tab-aliases') ->whenAvailable('@user-aliases', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 1) ->assertSeeIn('table tbody tr:first-child td:first-child', 'john.doe@kolab.org') ->assertMissing('table tfoot'); }); // Assert Subscriptions tab $browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (3)') ->click('@nav #tab-subscriptions') ->with('@user-subscriptions', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 3) ->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox') ->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '4,50 CHF/month¹') ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 5 GB') ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month¹') ->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features') ->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,41 CHF/month¹') ->assertMissing('table tfoot') ->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher'); }); // Assert Domains tab $browser->assertSeeIn('@nav #tab-domains', 'Domains (1)') ->click('@nav #tab-domains') ->with('@user-domains table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 1) ->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'kolab.org') ->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-success') ->assertMissing('tfoot'); }); // Assert Users tab $browser->assertSeeIn('@nav #tab-users', 'Users (4)') ->click('@nav #tab-users') ->with('@user-users table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 4) ->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'jack@kolab.org') ->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-success') ->assertSeeIn('tbody tr:nth-child(2) td:first-child a', 'joe@kolab.org') ->assertVisible('tbody tr:nth-child(2) td:first-child svg.text-success') ->assertSeeIn('tbody tr:nth-child(3) td:first-child span', 'john@kolab.org') ->assertVisible('tbody tr:nth-child(3) td:first-child svg.text-success') ->assertSeeIn('tbody tr:nth-child(4) td:first-child a', 'ned@kolab.org') ->assertVisible('tbody tr:nth-child(4) td:first-child svg.text-success') ->assertMissing('tfoot'); }); // Assert Distribution lists tab $browser->assertSeeIn('@nav #tab-distlists', 'Distribution lists (1)') ->click('@nav #tab-distlists') ->with('@user-distlists table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 1) ->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'Test Group') ->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-danger') ->assertSeeIn('tbody tr:nth-child(1) td:last-child a', 'group-test@kolab.org') ->assertMissing('tfoot'); }); // Assert Resources tab $browser->assertSeeIn('@nav #tab-resources', 'Resources (2)') ->click('@nav #tab-resources') ->with('@user-resources', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 2) ->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'Conference Room #1') ->assertSeeIn('table tbody tr:nth-child(1) td:last-child', 'resource-test1@kolab.org') ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Conference Room #2') ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', 'resource-test2@kolab.org') ->assertMissing('table tfoot'); }); // Assert Shared folders tab $browser->assertSeeIn('@nav #tab-folders', 'Shared folders (2)') ->click('@nav #tab-folders') ->with('@user-folders', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 2) ->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'Calendar') ->assertSeeIn('table tbody tr:nth-child(1) td:nth-child(2)', 'Calendar') ->assertSeeIn('table tbody tr:nth-child(1) td:last-child', 'folder-event@kolab.org') ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Contacts') ->assertSeeIn('table tbody tr:nth-child(2) td:nth-child(2)', 'Address Book') ->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 $this->browse(function (Browser $browser) { $ned = $this->getTestUser('ned@kolab.org'); $beta_sku = Sku::withEnvTenantContext()->where('title', 'beta')->first(); $storage_sku = Sku::withEnvTenantContext()->where('title', 'storage')->first(); $wallet = $ned->wallet(); // Add an extra storage and beta entitlement with different prices Entitlement::create([ 'wallet_id' => $wallet->id, 'sku_id' => $beta_sku->id, 'cost' => 5010, 'entitleable_id' => $ned->id, 'entitleable_type' => User::class ]); Entitlement::create([ 'wallet_id' => $wallet->id, 'sku_id' => $storage_sku->id, 'cost' => 5000, 'entitleable_id' => $ned->id, 'entitleable_type' => User::class ]); $page = new UserPage($ned->id); $ned->setSetting('greylist_enabled', 'false'); $browser->click('@nav #tab-users') ->click('@user-users tbody tr:nth-child(4) td:first-child a') ->on($page); // Assert main info box content $browser->assertSeeIn('@user-info .card-title', $ned->email) ->with('@user-info form', function (Browser $browser) use ($ned) { $browser->assertSeeIn('.row:nth-child(2) label', 'ID (Created)') ->assertSeeIn('.row:nth-child(2) #userid', "{$ned->id} ({$ned->created_at})"); }); // 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'); // Assert Aliases tab $browser->assertSeeIn('@nav #tab-aliases', 'Aliases (0)') ->click('@nav #tab-aliases') ->whenAvailable('@user-aliases', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'This user has no email aliases.'); }); // Assert Subscriptions tab, we expect John's discount here $browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (6)') ->click('@nav #tab-subscriptions') ->with('@user-subscriptions', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 6) ->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox') ->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '4,50 CHF/month¹') ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 6 GB') ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '45,00 CHF/month¹') ->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features') ->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,41 CHF/month¹') ->assertSeeIn('table tbody tr:nth-child(4) td:first-child', 'Activesync') ->assertSeeIn('table tbody tr:nth-child(4) td:last-child', '0,00 CHF/month¹') ->assertSeeIn('table tbody tr:nth-child(5) td:first-child', '2-Factor Authentication') ->assertSeeIn('table tbody tr:nth-child(5) td:last-child', '0,00 CHF/month¹') ->assertSeeIn('table tbody tr:nth-child(6) td:first-child', 'Private Beta (invitation only)') ->assertSeeIn('table tbody tr:nth-child(6) td:last-child', '45,09 CHF/month¹') ->assertMissing('table tfoot') ->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher') ->assertSeeIn('#reset2fa', 'Reset 2-Factor Auth') ->assertMissing('#addbetasku'); }); // We don't expect John's domains here $browser->assertSeeIn('@nav #tab-domains', 'Domains (0)') ->click('@nav #tab-domains') ->with('@user-domains', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no domains in this account.'); }); // We don't expect John's users here $browser->assertSeeIn('@nav #tab-users', 'Users (0)') ->click('@nav #tab-users') ->with('@user-users', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no users in this account.'); }); // We don't expect John's distribution lists here $browser->assertSeeIn('@nav #tab-distlists', 'Distribution lists (0)') ->click('@nav #tab-distlists') ->with('@user-distlists', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no distribution lists in this account.'); }); // We don't expect John's resources here $browser->assertSeeIn('@nav #tab-resources', 'Resources (0)') ->click('@nav #tab-resources') ->with('@user-resources', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no resources in this account.'); }); // We don't expect John's folders here $browser->assertSeeIn('@nav #tab-folders', 'Shared folders (0)') ->click('@nav #tab-folders') ->with('@user-folders', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no shared folders in this account.'); }); // Assert Settings tab $browser->assertSeeIn('@nav #tab-settings', 'Settings') ->click('@nav #tab-settings') ->whenAvailable('@user-settings form', function (Browser $browser) { $browser->assertElementsCount('.row', 3) ->assertSeeIn('.row:first-child label', 'Greylisting') ->assertSeeIn('.row:first-child .text-danger', 'disabled'); }); }); } /** * Test editing an external email * * @depends testUserInfo2 */ public function testExternalEmail(): void { $this->browse(function (Browser $browser) { $john = $this->getTestUser('john@kolab.org'); $browser->visit(new UserPage($john->id)) ->waitFor('@user-info #external_email button') ->click('@user-info #external_email button') // Test dialog content, and closing it with Cancel button ->with(new Dialog('#email-dialog'), function (Browser $browser) { $browser->assertSeeIn('@title', 'External Email') ->assertFocused('@body input') ->assertValue('@body input', 'john.doe.external@gmail.com') ->assertSeeIn('@button-cancel', 'Cancel') ->assertSeeIn('@button-action', 'Submit') ->click('@button-cancel'); }) ->assertMissing('#email-dialog') ->click('@user-info #external_email button') // Test email validation error handling, and email update ->with(new Dialog('#email-dialog'), function (Browser $browser) { $browser->type('@body input', 'test') ->click('@button-action') ->waitFor('@body input.is-invalid') ->assertSeeIn( '@body input + .invalid-feedback', 'The external email must be a valid email address.' ) ->assertToast(Toast::TYPE_ERROR, 'Form validation error') ->type('@body input', 'test@test.com') ->click('@button-action'); }) ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.') ->assertSeeIn('@user-info #external_email a', 'test@test.com') ->click('@user-info #external_email button') ->with(new Dialog('#email-dialog'), function (Browser $browser) { $browser->assertValue('@body input', 'test@test.com') ->assertMissing('@body input.is-invalid') ->assertMissing('@body input + .invalid-feedback') ->click('@button-cancel'); }) ->assertSeeIn('@user-info #external_email a', 'test@test.com'); // $john->getSetting() may not work here as it uses internal cache // read the value form database $current_ext_email = $john->settings()->where('key', 'external_email')->first()->value; $this->assertSame('test@test.com', $current_ext_email); }); } /** * Test suspending/unsuspending the user */ public function testSuspendAndUnsuspend(): void { $this->browse(function (Browser $browser) { $john = $this->getTestUser('john@kolab.org'); $browser->visit(new UserPage($john->id)) ->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); }); } /** * Test the Resync button */ public function testResync(): void { $this->browse(function (Browser $browser) { $john = $this->getTestUser('john@kolab.org'); $browser->visit(new UserPage($john->id)) ->assertSeeIn('@user-info #button-resync', 'Resync') ->click('@user-info #button-resync') ->assertToast(Toast::TYPE_SUCCESS, "User synchronization has been started."); }); } /** * Test resetting 2FA for the user */ public function testReset2FA(): void { $this->browse(function (Browser $browser) { $user = $this->getTestUser('userstest1@kolabnow.com'); $sku2fa = Sku::withEnvTenantContext()->where('title', '2fa')->first(); $user->assignSku($sku2fa); $user->restrict(); SecondFactor::seed('userstest1@kolabnow.com'); $browser->visit(new UserPage($user->id)) ->assertSeeIn('@user-info #status', 'Restricted') ->click('@nav #tab-subscriptions') ->with('@user-subscriptions', function (Browser $browser) use ($sku2fa) { $browser->waitFor('#reset2fa') ->assertVisible('#sku' . $sku2fa->id); }) ->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (1)') ->click('#reset2fa') ->with(new Dialog('#reset-2fa-dialog'), function (Browser $browser) { $browser->assertSeeIn('@title', '2-Factor Authentication Reset') ->assertSeeIn('@button-cancel', 'Cancel') ->assertSeeIn('@button-action', 'Reset') ->click('@button-action'); }) ->assertToast(Toast::TYPE_SUCCESS, '2-Factor authentication reset successfully.') ->assertMissing('#sku' . $sku2fa->id) ->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (0)'); }); } /** * Test resetting Geo-Lock for the user */ public function testResetGeoLock(): void { $this->browse(function (Browser $browser) { $user = $this->getTestUser('userstest1@kolabnow.com'); $user->setSetting('limit_geo', '["PL","DE"]'); $browser->visit(new UserPage($user->id)) ->click('@nav #tab-settings') ->whenAvailable('@user-settings form', function (Browser $browser) { $browser->assertSeeIn('.row:nth-child(3) label', 'Geo-lockin') ->assertSeeIn('.row:nth-child(3) #limit_geo', 'Poland, Germany') ->assertSeeIn('#limit_geo + button', 'Reset') ->click('#limit_geo + button'); }) ->assertToast(Toast::TYPE_SUCCESS, 'Geo-lockin setup reset successfully.') ->assertMissing('#limit_geo + button'); }); } /** * Test adding the beta SKU for the user */ public function testAddBetaSku(): void { $this->browse(function (Browser $browser) { $user = $this->getTestUser('userstest1@kolabnow.com'); $sku = Sku::withEnvTenantContext()->where('title', 'beta')->first(); $browser->visit(new UserPage($user->id)) ->click('@nav #tab-subscriptions') ->waitFor('@user-subscriptions #addbetasku') ->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (0)') ->assertSeeIn('#addbetasku', 'Enable beta program') ->click('#addbetasku') ->assertToast(Toast::TYPE_SUCCESS, 'The subscription added successfully.') ->waitFor('#sku' . $sku->id) ->assertSeeIn("#sku{$sku->id} td:first-child", 'Private Beta (invitation only)') ->assertSeeIn("#sku{$sku->id} td:last-child", '0,00 CHF/month') ->assertMissing('#addbetasku') ->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (1)'); }); } } diff --git a/src/tests/Browser/Pages/Admin/Distlist.php b/src/tests/Browser/Pages/Admin/Distlist.php index ecec0484..cb532eba 100644 --- a/src/tests/Browser/Pages/Admin/Distlist.php +++ b/src/tests/Browser/Pages/Admin/Distlist.php @@ -1,57 +1,59 @@ listid = $listid; } /** * Get the URL for the page. * * @return string */ public function url(): string { return '/distlist/' . $this->listid; } /** * Assert that the browser is on the page. * * @param \Laravel\Dusk\Browser $browser The browser object * * @return void */ public function assert($browser): void { $browser->waitForLocation($this->url()) + ->waitUntilMissing('@app .app-loader') ->waitFor('@distlist-info'); } /** * Get the element shortcuts for the page. * * @return array */ public function elements(): array { return [ '@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 index c8d81ee7..56920924 100644 --- a/src/tests/Browser/Pages/Admin/Domain.php +++ b/src/tests/Browser/Pages/Admin/Domain.php @@ -1,59 +1,61 @@ domainid = $domainid; } /** * Get the URL for the page. * * @return string */ public function url(): string { return '/domain/' . $this->domainid; } /** * Assert that the browser is on the page. * * @param \Laravel\Dusk\Browser $browser The browser object * * @return void */ public function assert($browser): void { $browser->waitForLocation($this->url()) + ->waitUntilMissing('@app .app-loader') ->waitFor('@domain-info'); } /** * Get the element shortcuts for the page. * * @return array */ public function elements(): array { return [ '@app' => '#app', '@domain-info' => '#domain-info', '@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 index 4123ac11..132bf0ea 100644 --- a/src/tests/Browser/Pages/Admin/Resource.php +++ b/src/tests/Browser/Pages/Admin/Resource.php @@ -1,57 +1,58 @@ resourceId = $id; } /** * Get the URL for the page. * * @return string */ public function url(): string { return '/resource/' . $this->resourceId; } /** * Assert that the browser is on the page. * * @param \Laravel\Dusk\Browser $browser The browser object * * @return void */ public function assert($browser): void { $browser->waitForLocation($this->url()) + ->waitUntilMissing('@app .app-loader') ->waitFor('@resource-info'); } /** * Get the element shortcuts for the page. * * @return array */ public function elements(): array { return [ '@app' => '#app', '@resource-info' => '#resource-info', '@resource-settings' => '#settings', ]; } } diff --git a/src/tests/Browser/Pages/Admin/SharedFolder.php b/src/tests/Browser/Pages/Admin/SharedFolder.php index 3080f8f7..b54ac24a 100644 --- a/src/tests/Browser/Pages/Admin/SharedFolder.php +++ b/src/tests/Browser/Pages/Admin/SharedFolder.php @@ -1,58 +1,59 @@ folderId = $id; } /** * Get the URL for the page. * * @return string */ public function url(): string { return '/shared-folder/' . $this->folderId; } /** * Assert that the browser is on the page. * * @param \Laravel\Dusk\Browser $browser The browser object * * @return void */ public function assert($browser): void { $browser->waitForLocation($this->url()) + ->waitUntilMissing('@app .app-loader') ->waitFor('@folder-info'); } /** * Get the element shortcuts for the page. * * @return array */ public function elements(): array { return [ '@app' => '#app', '@folder-info' => '#folder-info', '@folder-settings' => '#settings', '@folder-aliases' => '#aliases', ]; } } diff --git a/src/tests/Browser/Pages/Admin/User.php b/src/tests/Browser/Pages/Admin/User.php index 08ebb762..9f0019a1 100644 --- a/src/tests/Browser/Pages/Admin/User.php +++ b/src/tests/Browser/Pages/Admin/User.php @@ -1,67 +1,68 @@ userid = $userid; } /** * Get the URL for the page. * * @return string */ public function url(): string { return '/user/' . $this->userid; } /** * Assert that the browser is on the page. * * @param \Laravel\Dusk\Browser $browser The browser object * * @return void */ public function assert($browser): void { $browser->waitForLocation($this->url()) ->waitUntilMissing('@app .app-loader') ->waitFor('@user-info'); } /** * Get the element shortcuts for the page. * * @return array */ public function elements(): array { return [ '@app' => '#app', '@user-info' => '#user-info', '@nav' => 'ul.nav-tabs', '@user-finances' => '#finances', '@user-aliases' => '#aliases', '@user-subscriptions' => '#subscriptions', '@user-distlists' => '#distlists', '@user-domains' => '#domains', + '@user-history' => '#history', '@user-resources' => '#resources', '@user-folders' => '#folders', '@user-users' => '#users', '@user-settings' => '#settings', ]; } } diff --git a/src/tests/Browser/Reseller/DistlistTest.php b/src/tests/Browser/Reseller/DistlistTest.php index bc15aaee..c9028765 100644 --- a/src/tests/Browser/Reseller/DistlistTest.php +++ b/src/tests/Browser/Reseller/DistlistTest.php @@ -1,138 +1,166 @@ deleteTestGroup('group-test@kolab.org'); + Eventlog::query()->delete(); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestGroup('group-test@kolab.org'); + Eventlog::query()->delete(); parent::tearDown(); } /** * Test distlist info page (unauthenticated) */ public function testDistlistUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $user = $this->getTestUser('john@kolab.org'); $group = $this->getTestGroup('group-test@kolab.org'); $group->assignToWallet($user->wallets->first()); $browser->visit('/distlist/' . $group->id)->on(new Home()); }); } /** * Test distribution list info page */ public function testInfo(): void { Queue::fake(); $this->browse(function (Browser $browser) { $user = $this->getTestUser('john@kolab.org'); $group = $this->getTestGroup('group-test@kolab.org', ['name' => 'Test Group']); $group->assignToWallet($user->wallets->first()); $group->members = ['test1@gmail.com', 'test2@gmail.com']; $group->save(); $group->setConfig(['sender_policy' => ['test1.com', 'test2.com']]); $distlist_page = new DistlistPage($group->id); $user_page = new UserPage($user->id); // Goto the distlist page $browser->visit(new Home()) ->submitLogon('reseller@' . \config('app.domain'), \App\Utils::generatePassphrase(), true) ->on(new Dashboard()) ->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) ->with('@distlist-info form', function (Browser $browser) use ($group) { $browser->assertElementsCount('.row', 4) ->assertSeeIn('.row:nth-child(1) label', 'ID (Created)') ->assertSeeIn('.row:nth-child(1) #distlistid', "{$group->id} ({$group->created_at})") ->assertSeeIn('.row:nth-child(2) label', 'Status') ->assertSeeIn('.row:nth-child(2) #status.text-danger', 'Not Ready') ->assertSeeIn('.row:nth-child(3) label', 'Name') ->assertSeeIn('.row:nth-child(3) #name', $group->name) ->assertSeeIn('.row:nth-child(4) label', 'Recipients') ->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); }); } /** * Test suspending/unsuspending a distribution list * * @depends testInfo */ public function testSuspendAndUnsuspend(): void { Queue::fake(); + Eventlog::query()->delete(); $this->browse(function (Browser $browser) { $user = $this->getTestUser('john@kolab.org'); $group = $this->getTestGroup('group-test@kolab.org'); $group->assignToWallet($user->wallets->first()); $group->status = Group::STATUS_ACTIVE | Group::STATUS_LDAP_READY; $group->save(); $browser->visit(new DistlistPage($group->id)) ->assertVisible('@distlist-info #button-suspend') ->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 index 1ee3dd8b..c52b1555 100644 --- a/src/tests/Browser/Reseller/DomainTest.php +++ b/src/tests/Browser/Reseller/DomainTest.php @@ -1,136 +1,168 @@ deleteTestUser('test1@domainscontroller.com'); $this->deleteTestDomain('domainscontroller.com'); + Eventlog::query()->delete(); + self::useResellerUrl(); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('test1@domainscontroller.com'); $this->deleteTestDomain('domainscontroller.com'); + Eventlog::query()->delete(); + parent::tearDown(); } /** * Test domain info page (unauthenticated) */ public function testDomainUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $domain = $this->getTestDomain('kolab.org'); $browser->visit('/domain/' . $domain->id)->on(new Home()); }); } /** * Test domain info page */ public function testDomainInfo(): void { $this->browse(function (Browser $browser) { $domain = $this->getTestDomain('kolab.org'); $domain_page = new DomainPage($domain->id); $reseller = $this->getTestUser('reseller@' . \config('app.domain')); $user = $this->getTestUser('john@kolab.org'); $user_page = new UserPage($user->id); // Goto the domain page $browser->visit(new Home()) ->submitLogon('reseller@' . \config('app.domain'), \App\Utils::generatePassphrase(), true) ->on(new Dashboard()) ->visit($user_page) ->on($user_page) ->click('@nav #tab-domains') ->pause(1000) ->click('@user-domains table tbody tr:first-child td a'); $browser->on($domain_page) ->assertSeeIn('@domain-info .card-title', 'kolab.org') ->with('@domain-info form', function (Browser $browser) use ($domain) { $browser->assertElementsCount('.row', 2) ->assertSeeIn('.row:nth-child(1) label', 'ID (Created)') ->assertSeeIn('.row:nth-child(1) #domainid', "{$domain->id} ({$domain->created_at})") ->assertSeeIn('.row:nth-child(2) label', 'Status') ->assertSeeIn('.row:nth-child(2) #status span.text-success', 'Active'); }); // 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') ->with('@domain-config', function (Browser $browser) { $browser->assertSeeIn('pre#dns-verify', 'kolab-verify.kolab.org.') ->assertSeeIn('pre#dns-config', 'kolab.org.'); }); }); } /** * Test suspending/unsuspending a domain * * @depends testDomainInfo */ 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'); $domain = $this->getTestDomain('domainscontroller.com', [ 'status' => Domain::STATUS_NEW | Domain::STATUS_ACTIVE | Domain::STATUS_LDAP_READY | Domain::STATUS_CONFIRMED | Domain::STATUS_VERIFIED, 'type' => Domain::TYPE_EXTERNAL, ]); \App\Entitlement::create([ 'wallet_id' => $user->wallets()->first()->id, 'sku_id' => $sku_domain->id, 'entitleable_id' => $domain->id, 'entitleable_type' => Domain::class ]); $browser->visit(new DomainPage($domain->id)) ->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 index 1538b11e..0f29bce7 100644 --- a/src/tests/Browser/Reseller/UserTest.php +++ b/src/tests/Browser/Reseller/UserTest.php @@ -1,588 +1,617 @@ getTestUser('john@kolab.org'); $john->setSettings([ 'phone' => '+48123123123', 'external_email' => 'john.doe.external@gmail.com', ]); if ($john->isSuspended()) { User::where('email', $john->email)->update(['status' => $john->status - User::STATUS_SUSPENDED]); } $wallet = $john->wallets()->first(); $wallet->discount()->dissociate(); $wallet->save(); $this->deleteTestGroup('group-test@kolab.org'); $this->deleteTestUser('userstest1@kolabnow.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $john = $this->getTestUser('john@kolab.org'); $john->setSettings([ 'phone' => null, 'external_email' => 'john.doe.external@gmail.com', ]); if ($john->isSuspended()) { User::where('email', $john->email)->update(['status' => $john->status - User::STATUS_SUSPENDED]); } $wallet = $john->wallets()->first(); $wallet->discount()->dissociate(); $wallet->save(); $this->deleteTestGroup('group-test@kolab.org'); $this->deleteTestUser('userstest1@kolabnow.com'); parent::tearDown(); } /** * Test user info page (unauthenticated) */ public function testUserUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $jack = $this->getTestUser('jack@kolab.org'); $browser->visit('/user/' . $jack->id)->on(new Home()); }); } /** * Test user info page */ public function testUserInfo(): void { $this->browse(function (Browser $browser) { $jack = $this->getTestUser('jack@kolab.org'); $jack->setSettings([ 'limit_geo' => null, 'organization' => null, ]); $page = new UserPage($jack->id); $browser->visit(new Home()) ->submitLogon('reseller@' . \config('app.domain'), \App\Utils::generatePassphrase(), true) ->on(new Dashboard()) ->visit($page) ->on($page); // Assert main info box content $browser->assertSeeIn('@user-info .card-title', $jack->email) ->with('@user-info form', function (Browser $browser) use ($jack) { $browser->assertElementsCount('.row', 7) ->assertSeeIn('.row:nth-child(1) label', 'Managed by') ->assertSeeIn('.row:nth-child(1) #manager a', 'john@kolab.org') ->assertSeeIn('.row:nth-child(2) label', 'ID (Created)') ->assertSeeIn('.row:nth-child(2) #userid', "{$jack->id} ({$jack->created_at})") ->assertSeeIn('.row:nth-child(3) label', 'Status') ->assertSeeIn('.row:nth-child(3) #status span.text-success', 'Active') ->assertSeeIn('.row:nth-child(4) label', 'First Name') ->assertSeeIn('.row:nth-child(4) #first_name', 'Jack') ->assertSeeIn('.row:nth-child(5) label', 'Last Name') ->assertSeeIn('.row:nth-child(5) #last_name', 'Daniels') ->assertSeeIn('.row:nth-child(6) label', 'External Email') ->assertMissing('.row:nth-child(6) #external_email a') ->assertSeeIn('.row:nth-child(7) label', 'Country') ->assertSeeIn('.row:nth-child(7) #country', 'United States'); }); // 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'); // Assert Aliases tab $browser->assertSeeIn('@nav #tab-aliases', 'Aliases (1)') ->click('@nav #tab-aliases') ->whenAvailable('@user-aliases', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 1) ->assertSeeIn('table tbody tr:first-child td:first-child', 'jack.daniels@kolab.org') ->assertMissing('table tfoot'); }); // Assert Subscriptions tab $browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (3)') ->click('@nav #tab-subscriptions') ->with('@user-subscriptions', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 3) ->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox') ->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '5,00 CHF/month') ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 5 GB') ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month') ->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features') ->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,90 CHF/month') ->assertMissing('table tfoot') ->assertMissing('#reset2fa'); }); // Assert Domains tab $browser->assertSeeIn('@nav #tab-domains', 'Domains (0)') ->click('@nav #tab-domains') ->with('@user-domains', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no domains in this account.'); }); // Assert Users tab $browser->assertSeeIn('@nav #tab-users', 'Users (0)') ->click('@nav #tab-users') ->with('@user-users', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no users in this account.'); }); // Assert Distribution lists tab $browser->assertSeeIn('@nav #tab-distlists', 'Distribution lists (0)') ->click('@nav #tab-distlists') ->with('@user-distlists', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no distribution lists in this account.'); }); // Assert Resources tab $browser->assertSeeIn('@nav #tab-resources', 'Resources (0)') ->click('@nav #tab-resources') ->with('@user-resources', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no resources in this account.'); }); // Assert Shared folders tab $browser->assertSeeIn('@nav #tab-folders', 'Shared folders (0)') ->click('@nav #tab-folders') ->with('@user-folders', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no shared folders in this account.'); }); // Assert Settings tab $browser->assertSeeIn('@nav #tab-settings', 'Settings') ->click('@nav #tab-settings') ->whenAvailable('@user-settings form', function (Browser $browser) { $browser->assertElementsCount('.row', 3) ->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') ->assertMissing('#limit_geo + button'); }); }); } /** * Test user info page (continue) * * @depends testUserInfo */ public function testUserInfo2(): void { $this->browse(function (Browser $browser) { $john = $this->getTestUser('john@kolab.org'); $page = new UserPage($john->id); $discount = Discount::where('code', 'TEST')->first(); $wallet = $john->wallet(); $wallet->discount()->associate($discount); $wallet->debit(2010); $wallet->save(); $group = $this->getTestGroup('group-test@kolab.org', ['name' => 'Test Group']); $group->assignToWallet($john->wallets->first()); // Click the managed-by link on Jack's page $browser->click('@user-info #manager a') ->on($page); // Assert main info box content $browser->assertSeeIn('@user-info .card-title', $john->email) ->with('@user-info form', function (Browser $browser) use ($john) { $ext_email = $john->getSetting('external_email'); $browser->assertElementsCount('.row', 9) ->assertSeeIn('.row:nth-child(1) label', 'ID (Created)') ->assertSeeIn('.row:nth-child(1) #userid', "{$john->id} ({$john->created_at})") ->assertSeeIn('.row:nth-child(2) label', 'Status') ->assertSeeIn('.row:nth-child(2) #status span.text-success', 'Active') ->assertSeeIn('.row:nth-child(3) label', 'First Name') ->assertSeeIn('.row:nth-child(3) #first_name', 'John') ->assertSeeIn('.row:nth-child(4) label', 'Last Name') ->assertSeeIn('.row:nth-child(4) #last_name', 'Doe') ->assertSeeIn('.row:nth-child(5) label', 'Organization') ->assertSeeIn('.row:nth-child(5) #organization', 'Kolab Developers') ->assertSeeIn('.row:nth-child(6) label', 'Phone') ->assertSeeIn('.row:nth-child(6) #phone', $john->getSetting('phone')) ->assertSeeIn('.row:nth-child(7) label', 'External Email') ->assertSeeIn('.row:nth-child(7) #external_email a', $ext_email) ->assertAttribute('.row:nth-child(7) #external_email a', 'href', "mailto:$ext_email") ->assertSeeIn('.row:nth-child(8) label', 'Address') ->assertSeeIn('.row:nth-child(8) #billing_address', $john->getSetting('billing_address')) ->assertSeeIn('.row:nth-child(9) label', 'Country') ->assertSeeIn('.row:nth-child(9) #country', 'United States'); }); // 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'); // Assert Aliases tab $browser->assertSeeIn('@nav #tab-aliases', 'Aliases (1)') ->click('@nav #tab-aliases') ->whenAvailable('@user-aliases', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 1) ->assertSeeIn('table tbody tr:first-child td:first-child', 'john.doe@kolab.org') ->assertMissing('table tfoot'); }); // Assert Subscriptions tab $browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (3)') ->click('@nav #tab-subscriptions') ->with('@user-subscriptions', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 3) ->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox') ->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '4,50 CHF/month¹') ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 5 GB') ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month¹') ->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features') ->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,41 CHF/month¹') ->assertMissing('table tfoot') ->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher'); }); // Assert Users tab $browser->assertSeeIn('@nav #tab-users', 'Users (4)') ->click('@nav #tab-users') ->with('@user-users table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 4) ->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'jack@kolab.org') ->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-success') ->assertSeeIn('tbody tr:nth-child(2) td:first-child a', 'joe@kolab.org') ->assertVisible('tbody tr:nth-child(2) td:first-child svg.text-success') ->assertSeeIn('tbody tr:nth-child(3) td:first-child span', 'john@kolab.org') ->assertVisible('tbody tr:nth-child(3) td:first-child svg.text-success') ->assertSeeIn('tbody tr:nth-child(4) td:first-child a', 'ned@kolab.org') ->assertVisible('tbody tr:nth-child(4) td:first-child svg.text-success') ->assertMissing('tfoot'); }); // Assert Domains tab $browser->assertSeeIn('@nav #tab-domains', 'Domains (1)') ->click('@nav #tab-domains') ->with('@user-domains table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 1) ->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'kolab.org') ->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-success') ->assertMissing('tfoot'); }); // Assert Distribution lists tab $browser->assertSeeIn('@nav #tab-distlists', 'Distribution lists (1)') ->click('@nav #tab-distlists') ->with('@user-distlists table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 1) ->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'Test Group') ->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-danger') ->assertSeeIn('tbody tr:nth-child(1) td:last-child a', 'group-test@kolab.org') ->assertMissing('tfoot'); }); // Assert Resources tab $browser->assertSeeIn('@nav #tab-resources', 'Resources (2)') ->click('@nav #tab-resources') ->with('@user-resources', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 2) ->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'Conference Room #1') ->assertSeeIn('table tbody tr:nth-child(1) td:last-child', 'resource-test1@kolab.org') ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Conference Room #2') ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', 'resource-test2@kolab.org') ->assertMissing('table tfoot'); }); // Assert Shared folders tab $browser->assertSeeIn('@nav #tab-folders', 'Shared folders (2)') ->click('@nav #tab-folders') ->with('@user-folders', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 2) ->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'Calendar') ->assertSeeIn('table tbody tr:nth-child(1) td:nth-child(2)', 'Calendar') ->assertSeeIn('table tbody tr:nth-child(1) td:last-child', 'folder-event@kolab.org') ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Contacts') ->assertSeeIn('table tbody tr:nth-child(2) td:nth-child(2)', 'Address Book') ->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 $this->browse(function (Browser $browser) { $ned = $this->getTestUser('ned@kolab.org'); $ned->setSetting('greylist_enabled', 'false'); $page = new UserPage($ned->id); $browser->click('@nav #tab-users') ->click('@user-users tbody tr:nth-child(4) td:first-child a') ->on($page); // Assert main info box content $browser->assertSeeIn('@user-info .card-title', $ned->email) ->with('@user-info form', function (Browser $browser) use ($ned) { $browser->assertSeeIn('.row:nth-child(2) label', 'ID (Created)') ->assertSeeIn('.row:nth-child(2) #userid', "{$ned->id} ({$ned->created_at})"); }); // 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'); // Assert Aliases tab $browser->assertSeeIn('@nav #tab-aliases', 'Aliases (0)') ->click('@nav #tab-aliases') ->whenAvailable('@user-aliases', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'This user has no email aliases.'); }); // Assert Subscriptions tab, we expect John's discount here $browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (5)') ->click('@nav #tab-subscriptions') ->with('@user-subscriptions', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 5) ->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox') ->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '4,50 CHF/month¹') ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 5 GB') ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month¹') ->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features') ->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,41 CHF/month¹') ->assertSeeIn('table tbody tr:nth-child(4) td:first-child', 'Activesync') ->assertSeeIn('table tbody tr:nth-child(4) td:last-child', '0,00 CHF/month¹') ->assertSeeIn('table tbody tr:nth-child(5) td:first-child', '2-Factor Authentication') ->assertSeeIn('table tbody tr:nth-child(5) td:last-child', '0,00 CHF/month¹') ->assertMissing('table tfoot') ->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher') ->assertSeeIn('#reset2fa', 'Reset 2-Factor Auth'); }); // We don't expect John's domains here $browser->assertSeeIn('@nav #tab-domains', 'Domains (0)') ->click('@nav #tab-domains') ->with('@user-domains', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no domains in this account.'); }); // We don't expect John's users here $browser->assertSeeIn('@nav #tab-users', 'Users (0)') ->click('@nav #tab-users') ->with('@user-users', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no users in this account.'); }); // We don't expect John's distribution lists here $browser->assertSeeIn('@nav #tab-distlists', 'Distribution lists (0)') ->click('@nav #tab-distlists') ->with('@user-distlists', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no distribution lists in this account.'); }); // Assert Resources tab $browser->assertSeeIn('@nav #tab-resources', 'Resources (0)') ->click('@nav #tab-resources') ->with('@user-resources', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no resources in this account.'); }); // Assert Shared folders tab $browser->assertSeeIn('@nav #tab-folders', 'Shared folders (0)') ->click('@nav #tab-folders') ->with('@user-folders', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no shared folders in this account.'); }); // Assert Settings tab $browser->assertSeeIn('@nav #tab-settings', 'Settings') ->click('@nav #tab-settings') ->whenAvailable('@user-settings form', function (Browser $browser) { $browser->assertElementsCount('.row', 3) ->assertSeeIn('.row:nth-child(1) label', 'Greylisting') ->assertSeeIn('.row:nth-child(1) .text-danger', 'disabled') ->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'); }); + + // Assert History tab + $browser->assertSeeIn('@nav #tab-history', 'History'); }); } /** * Test editing an external email * * @depends testUserInfo2 */ public function testExternalEmail(): void { $this->browse(function (Browser $browser) { $john = $this->getTestUser('john@kolab.org'); $browser->visit(new UserPage($john->id)) ->waitFor('@user-info #external_email button') ->click('@user-info #external_email button') // Test dialog content, and closing it with Cancel button ->with(new Dialog('#email-dialog'), function (Browser $browser) { $browser->assertSeeIn('@title', 'External Email') ->assertFocused('@body input') ->assertValue('@body input', 'john.doe.external@gmail.com') ->assertSeeIn('@button-cancel', 'Cancel') ->assertSeeIn('@button-action', 'Submit') ->click('@button-cancel'); }) ->assertMissing('#email-dialog') ->click('@user-info #external_email button') // Test email validation error handling, and email update ->with(new Dialog('#email-dialog'), function (Browser $browser) { $browser->type('@body input', 'test') ->click('@button-action') ->waitFor('@body input.is-invalid') ->assertSeeIn( '@body input + .invalid-feedback', 'The external email must be a valid email address.' ) ->assertToast(Toast::TYPE_ERROR, 'Form validation error') ->type('@body input', 'test@test.com') ->click('@button-action'); }) ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.') ->assertSeeIn('@user-info #external_email a', 'test@test.com') ->click('@user-info #external_email button') ->with(new Dialog('#email-dialog'), function (Browser $browser) { $browser->assertValue('@body input', 'test@test.com') ->assertMissing('@body input.is-invalid') ->assertMissing('@body input + .invalid-feedback') ->click('@button-cancel'); }) ->assertSeeIn('@user-info #external_email a', 'test@test.com'); // $john->getSetting() may not work here as it uses internal cache // read the value form database $current_ext_email = $john->settings()->where('key', 'external_email')->first()->value; $this->assertSame('test@test.com', $current_ext_email); }); } /** * Test suspending/unsuspending the user */ public function testSuspendAndUnsuspend(): void { + EventLog::query()->delete(); + $this->browse(function (Browser $browser) { $john = $this->getTestUser('john@kolab.org'); $browser->visit(new UserPage($john->id)) ->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); }); } /** * Test resetting 2FA for the user */ public function testReset2FA(): void { $this->browse(function (Browser $browser) { $user = $this->getTestUser('userstest1@kolabnow.com'); $sku2fa = Sku::withEnvTenantContext()->where(['title' => '2fa'])->first(); $user->assignSku($sku2fa); SecondFactor::seed('userstest1@kolabnow.com'); $browser->visit(new UserPage($user->id)) ->click('@nav #tab-subscriptions') ->with('@user-subscriptions', function (Browser $browser) use ($sku2fa) { $browser->waitFor('#reset2fa') ->assertVisible('#sku' . $sku2fa->id); }) ->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (1)') ->click('#reset2fa') ->with(new Dialog('#reset-2fa-dialog'), function (Browser $browser) { $browser->assertSeeIn('@title', '2-Factor Authentication Reset') ->assertSeeIn('@button-cancel', 'Cancel') ->assertSeeIn('@button-action', 'Reset') ->click('@button-action'); }) ->assertToast(Toast::TYPE_SUCCESS, '2-Factor authentication reset successfully.') ->assertMissing('#sku' . $sku2fa->id) ->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (0)'); }); } /** * Test adding the beta SKU for the user */ public function testAddBetaSku(): void { $this->browse(function (Browser $browser) { $user = $this->getTestUser('userstest1@kolabnow.com'); $sku = Sku::withEnvTenantContext()->where('title', 'beta')->first(); $browser->visit(new UserPage($user->id)) ->click('@nav #tab-subscriptions') ->waitFor('@user-subscriptions #addbetasku') ->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (0)') ->assertSeeIn('#addbetasku', 'Enable beta program') ->click('#addbetasku') ->assertToast(Toast::TYPE_SUCCESS, 'The subscription added successfully.') ->waitFor('#sku' . $sku->id) ->assertSeeIn("#sku{$sku->id} td:first-child", 'Private Beta (invitation only)') ->assertSeeIn("#sku{$sku->id} td:last-child", '0,00 CHF/month') ->assertMissing('#addbetasku') ->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (1)'); }); } } diff --git a/src/tests/Feature/Console/Group/SuspendTest.php b/src/tests/Feature/Console/Group/SuspendTest.php new file mode 100644 index 00000000..8b7115f0 --- /dev/null +++ b/src/tests/Feature/Console/Group/SuspendTest.php @@ -0,0 +1,75 @@ +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 index 00000000..06ce2a76 --- /dev/null +++ b/src/tests/Feature/Console/Group/UnsuspendTest.php @@ -0,0 +1,76 @@ +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 index cbec81fa..1b68d40f 100644 --- a/src/tests/Feature/Controller/Admin/DomainsTest.php +++ b/src/tests/Feature/Controller/Admin/DomainsTest.php @@ -1,249 +1,282 @@ deleteTestDomain('domainscontroller.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('test1@domainscontroller.com'); $this->deleteTestDomain('domainscontroller.com'); parent::tearDown(); } /** * Test domains confirming (not implemented) */ public function testConfirm(): void { $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $domain = $this->getTestDomain('kolab.org'); // This end-point does not exist for admins $response = $this->actingAs($admin)->get("api/v4/domains/{$domain->id}/confirm"); $response->assertStatus(404); } /** * Test deleting a domain (DELETE /api/v4/domains/) */ public function testDestroy(): void { $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $domain = $this->getTestDomain('kolab.org'); // This end-point does not exist for admins $response = $this->actingAs($admin)->delete("api/v4/domains/{$domain->id}"); $response->assertStatus(404); } /** * Test domains searching (/api/v4/domains) */ public function testIndex(): void { $john = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); // Non-admin user $response = $this->actingAs($john)->get("api/v4/domains"); $response->assertStatus(403); // Search with no search criteria $response = $this->actingAs($admin)->get("api/v4/domains"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); // Search with no matches expected $response = $this->actingAs($admin)->get("api/v4/domains?search=abcd12.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); // Search by a domain name $response = $this->actingAs($admin)->get("api/v4/domains?search=kolab.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame('kolab.org', $json['list'][0]['namespace']); // Search by owner $response = $this->actingAs($admin)->get("api/v4/domains?owner={$john->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame('kolab.org', $json['list'][0]['namespace']); // Search by owner (Ned is a controller on John's wallets, // here we expect only domains assigned to Ned's wallet(s)) $ned = $this->getTestUser('ned@kolab.org'); $response = $this->actingAs($admin)->get("api/v4/domains?owner={$ned->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertCount(0, $json['list']); } /** * Test fetching domain info */ public function testShow(): void { $sku_domain = Sku::where('title', 'domain-hosting')->first(); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $user = $this->getTestUser('test1@domainscontroller.com'); $domain = $this->getTestDomain('domainscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); Entitlement::create([ 'wallet_id' => $user->wallets()->first()->id, 'sku_id' => $sku_domain->id, 'entitleable_id' => $domain->id, 'entitleable_type' => Domain::class ]); // Only admins can access it $response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}"); $response->assertStatus(403); $response = $this->actingAs($admin)->get("api/v4/domains/{$domain->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals($domain->id, $json['id']); $this->assertEquals($domain->namespace, $json['namespace']); $this->assertEquals($domain->status, $json['status']); $this->assertEquals($domain->type, $json['type']); // Note: Other properties are being tested in the user controller tests } /** * Test fetching domain status (GET /api/v4/domains//status) */ public function testStatus(): void { $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $domain = $this->getTestDomain('kolab.org'); // This end-point does not exist for admins $response = $this->actingAs($admin)->get("/api/v4/domains/{$domain->id}/status"); $response->assertStatus(404); } /** * Test creeating a domain (POST /api/v4/domains) */ public function testStore(): void { $admin = $this->getTestUser('jeroen@jeroen.jeroen'); // Admins can't create domains $response = $this->actingAs($admin)->post("api/v4/domains", []); $response->assertStatus(404); } /** * Test domain suspending (POST /api/v4/domains//suspend) */ public function testSuspend(): void { Queue::fake(); // disable jobs $domain = $this->getTestDomain('domainscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); $user = $this->getTestUser('test1@domainscontroller.com'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); // Test unauthorized access to admin API $response = $this->actingAs($user)->post("/api/v4/domains/{$domain->id}/suspend", []); $response->assertStatus(403); $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); $json = $response->json(); $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()); } /** * Test user un-suspending (POST /api/v4/users//unsuspend) */ public function testUnsuspend(): void { Queue::fake(); // disable jobs $domain = $this->getTestDomain('domainscontroller.com', [ 'status' => Domain::STATUS_NEW | Domain::STATUS_SUSPENDED, 'type' => Domain::TYPE_EXTERNAL, ]); $user = $this->getTestUser('test1@domainscontroller.com'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); // Test unauthorized access to admin API $response = $this->actingAs($user)->post("/api/v4/domains/{$domain->id}/unsuspend", []); $response->assertStatus(403); $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); $json = $response->json(); $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 index 00000000..4345337f --- /dev/null +++ b/src/tests/Feature/Controller/Admin/EventLogTest.php @@ -0,0 +1,97 @@ +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 index dae05b1d..1a069929 100644 --- a/src/tests/Feature/Controller/Admin/GroupsTest.php +++ b/src/tests/Feature/Controller/Admin/GroupsTest.php @@ -1,227 +1,260 @@ deleteTestGroup('group-test@kolab.org'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestGroup('group-test@kolab.org'); parent::tearDown(); } /** * Test groups searching (/api/v4/groups) */ public function testIndex(): void { $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $group = $this->getTestGroup('group-test@kolab.org'); $group->assignToWallet($user->wallets->first()); // Non-admin user $response = $this->actingAs($user)->get("api/v4/groups"); $response->assertStatus(403); // Search with no search criteria $response = $this->actingAs($admin)->get("api/v4/groups"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); // Search with no matches expected $response = $this->actingAs($admin)->get("api/v4/groups?search=john@kolab.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); // Search by email $response = $this->actingAs($admin)->get("api/v4/groups?search={$group->email}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($group->email, $json['list'][0]['email']); // Search by owner $response = $this->actingAs($admin)->get("api/v4/groups?owner={$user->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($group->email, $json['list'][0]['email']); $this->assertSame($group->name, $json['list'][0]['name']); // Search by owner (Ned is a controller on John's wallets, // here we expect only groups assigned to Ned's wallet(s)) $ned = $this->getTestUser('ned@kolab.org'); $response = $this->actingAs($admin)->get("api/v4/groups?owner={$ned->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertCount(0, $json['list']); } /** * Test fetching group info */ public function testShow(): void { $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $user = $this->getTestUser('john@kolab.org'); $group = $this->getTestGroup('group-test@kolab.org'); $group->assignToWallet($user->wallets->first()); // Only admins can access it $response = $this->actingAs($user)->get("api/v4/groups/{$group->id}"); $response->assertStatus(403); $response = $this->actingAs($admin)->get("api/v4/groups/{$group->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals($group->id, $json['id']); $this->assertEquals($group->email, $json['email']); $this->assertEquals($group->name, $json['name']); $this->assertEquals($group->status, $json['status']); } /** * Test fetching group status (GET /api/v4/groups//status) */ public function testStatus(): void { Queue::fake(); // disable jobs $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $group = $this->getTestGroup('group-test@kolab.org'); $group->assignToWallet($user->wallets->first()); // This end-point does not exist for admins $response = $this->actingAs($admin)->get("/api/v4/groups/{$group->id}/status"); $response->assertStatus(404); } /** * Test group creating (POST /api/v4/groups) */ public function testStore(): void { $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); // Test unauthorized access to admin API $response = $this->actingAs($user)->post("/api/v4/groups", []); $response->assertStatus(403); // Admin can't create groups $response = $this->actingAs($admin)->post("/api/v4/groups", []); $response->assertStatus(404); } /** * Test group suspending (POST /api/v4/groups//suspend) */ public function testSuspend(): void { Queue::fake(); // disable jobs $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $group = $this->getTestGroup('group-test@kolab.org'); $group->assignToWallet($user->wallets->first()); // Test unauthorized access to admin API $response = $this->actingAs($user)->post("/api/v4/groups/{$group->id}/suspend", []); $response->assertStatus(403); // Test non-existing group ID $response = $this->actingAs($admin)->post("/api/v4/groups/abc/suspend", []); $response->assertStatus(404); $this->assertFalse($group->fresh()->isSuspended()); // Test suspending the group $response = $this->actingAs($admin)->post("/api/v4/groups/{$group->id}/suspend", []); $response->assertStatus(200); $json = $response->json(); $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()); } /** * Test user un-suspending (POST /api/v4/users//unsuspend) */ public function testUnsuspend(): void { Queue::fake(); // disable jobs $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $group = $this->getTestGroup('group-test@kolab.org'); $group->assignToWallet($user->wallets->first()); $group->status |= Group::STATUS_SUSPENDED; $group->save(); // Test unauthorized access to admin API $response = $this->actingAs($user)->post("/api/v4/groups/{$group->id}/unsuspend", []); $response->assertStatus(403); // Invalid group ID $response = $this->actingAs($admin)->post("/api/v4/groups/abc/unsuspend", []); $response->assertStatus(404); $this->assertTrue($group->fresh()->isSuspended()); // Test suspending the group $response = $this->actingAs($admin)->post("/api/v4/groups/{$group->id}/unsuspend", []); $response->assertStatus(200); $json = $response->json(); $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 index c419d8c4..114f6582 100644 --- a/src/tests/Feature/Controller/Admin/UsersTest.php +++ b/src/tests/Feature/Controller/Admin/UsersTest.php @@ -1,586 +1,620 @@ deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestUser('test@testsearch.com'); $this->deleteTestDomain('testsearch.com'); $this->deleteTestGroup('group-test@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $jack->setSetting('external_email', null); \App\SharedFolderAlias::truncate(); \App\Payment::query()->delete(); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestUser('test@testsearch.com'); $this->deleteTestDomain('testsearch.com'); $jack = $this->getTestUser('jack@kolab.org'); $jack->setSetting('external_email', null); \App\SharedFolderAlias::truncate(); \App\Payment::query()->delete(); parent::tearDown(); } /** * Test user deleting (DELETE /api/v4/users/) */ public function testDestroy(): void { $john = $this->getTestUser('john@kolab.org'); $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); // Test unauth access $response = $this->delete("api/v4/users/{$user->id}"); $response->assertStatus(401); // The end-point does not exist $response = $this->actingAs($admin)->delete("api/v4/users/{$user->id}"); $response->assertStatus(404); } /** * Test users searching (/api/v4/users) */ public function testIndex(): void { $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $group = $this->getTestGroup('group-test@kolab.org'); $group->assignToWallet($user->wallets->first()); // Non-admin user $response = $this->actingAs($user)->get("api/v4/users"); $response->assertStatus(403); // Search with no search criteria $response = $this->actingAs($admin)->get("api/v4/users"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); // Search with no matches expected $response = $this->actingAs($admin)->get("api/v4/users?search=abcd1234efgh5678"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); // Search by domain $response = $this->actingAs($admin)->get("api/v4/users?search=kolab.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); // Search by user ID $response = $this->actingAs($admin)->get("api/v4/users?search={$user->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); // Search by email (primary) $response = $this->actingAs($admin)->get("api/v4/users?search=john@kolab.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); // Search by email (alias) $response = $this->actingAs($admin)->get("api/v4/users?search=john.doe@kolab.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); // Search by email (external), expect two users in a result $jack = $this->getTestUser('jack@kolab.org'); $jack->setSetting('external_email', 'john.doe.external@gmail.com'); $response = $this->actingAs($admin)->get("api/v4/users?search=john.doe.external@gmail.com"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(2, $json['count']); $this->assertCount(2, $json['list']); $emails = array_column($json['list'], 'email'); $this->assertContains($user->email, $emails); $this->assertContains($jack->email, $emails); // Search by owner $response = $this->actingAs($admin)->get("api/v4/users?owner={$user->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(4, $json['count']); $this->assertCount(4, $json['list']); // Search by owner (Ned is a controller on John's wallets, // here we expect only users assigned to Ned's wallet(s)) $ned = $this->getTestUser('ned@kolab.org'); $response = $this->actingAs($admin)->get("api/v4/users?owner={$ned->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertCount(0, $json['list']); // Search by distribution list email $response = $this->actingAs($admin)->get("api/v4/users?search=group-test@kolab.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); // Search by resource email $response = $this->actingAs($admin)->get("api/v4/users?search=resource-test1@kolab.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); // Search by shared folder email $response = $this->actingAs($admin)->get("api/v4/users?search=folder-event@kolab.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); // Search by shared folder alias $folder = $this->getTestSharedFolder('folder-event@kolab.org'); $folder->setAliases(['folder-alias@kolab.org']); $response = $this->actingAs($admin)->get("api/v4/users?search=folder-alias@kolab.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); // Deleted users/domains $domain = $this->getTestDomain('testsearch.com', ['type' => \App\Domain::TYPE_EXTERNAL]); $user = $this->getTestUser('test@testsearch.com'); $plan = \App\Plan::where('title', 'group')->first(); $user->assignPlan($plan, $domain); $user->setAliases(['alias@testsearch.com']); $wallet = $user->wallets()->first(); $wallet->setSetting('mollie_id', 'cst_nonsense'); \App\Payment::create( [ 'id' => 'tr_nonsense', 'wallet_id' => $wallet->id, 'status' => 'paid', 'amount' => 1337, 'credit_amount' => 1337, 'description' => 'nonsense transaction for testing', 'provider' => 'self', 'type' => 'oneoff', 'currency' => 'CHF', 'currency_amount' => 1337 ] ); Queue::fake(); $user->delete(); $response = $this->actingAs($admin)->get("api/v4/users?search=test@testsearch.com"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); $this->assertTrue($json['list'][0]['isDeleted']); $response = $this->actingAs($admin)->get("api/v4/users?search=alias@testsearch.com"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); $this->assertTrue($json['list'][0]['isDeleted']); $response = $this->actingAs($admin)->get("api/v4/users?search=testsearch.com"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); $this->assertTrue($json['list'][0]['isDeleted']); $response = $this->actingAs($admin)->get("api/v4/users?search={$wallet->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); $this->assertTrue($json['list'][0]['isDeleted']); $response = $this->actingAs($admin)->get("api/v4/users?search=tr_nonsense"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); $this->assertTrue($json['list'][0]['isDeleted']); $response = $this->actingAs($admin)->get("api/v4/users?search=cst_nonsense"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); $this->assertTrue($json['list'][0]['isDeleted']); } /** * Test reseting 2FA (POST /api/v4/users//reset2FA) */ public function testReset2FA(): void { Queue::fake(); $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $sku2fa = Sku::withEnvTenantContext()->where(['title' => '2fa'])->first(); $user->assignSku($sku2fa); SecondFactor::seed('userscontrollertest1@userscontroller.com'); // Test unauthorized access to admin API $response = $this->actingAs($user)->post("/api/v4/users/{$user->id}/reset2FA", []); $response->assertStatus(403); $entitlements = $user->fresh()->entitlements()->where('sku_id', $sku2fa->id)->get(); $this->assertCount(1, $entitlements); $sf = new SecondFactor($user); $this->assertCount(1, $sf->factors()); // Test reseting 2FA $response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/reset2FA", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("2-Factor authentication reset successfully.", $json['message']); $this->assertCount(2, $json); $entitlements = $user->fresh()->entitlements()->where('sku_id', $sku2fa->id)->get(); $this->assertCount(0, $entitlements); $sf = new SecondFactor($user); $this->assertCount(0, $sf->factors()); } /** * Test reseting Geo-Lock (POST /api/v4/users//resetGeoLock) */ public function testResetGeoLock(): void { Queue::fake(); $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $user->setConfig(['limit_geo' => ['US']]); // Test unauthorized access to admin API $response = $this->actingAs($user)->post("/api/v4/users/{$user->id}/resetGeoLock", []); $response->assertStatus(403); // Test reseting Geo-Lock $response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/resetGeoLock", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("Geo-lockin setup reset successfully.", $json['message']); $this->assertCount(2, $json); $this->assertNull($user->getSetting('limit_geo')); } /** * Test resync (POST /api/v4/users//resync) */ public function testResync(): void { Queue::fake(); $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); // Test unauthorized access to admin API $response = $this->actingAs($user)->post("/api/v4/users/{$user->id}/resync", []); $response->assertStatus(403); // Test resync \Artisan::shouldReceive('call')->once()->with('user:resync', ['user' => $user->id]); $response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/resync", []); $response->assertStatus(200); $json = $response->json(); $this->assertCount(2, $json); $this->assertSame('success', $json['status']); $this->assertSame("User synchronization has been started.", $json['message']); } /** * Test adding beta SKU (POST /api/v4/users//skus/beta) */ public function testAddBetaSku(): void { Queue::fake(); $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $sku = Sku::withEnvTenantContext()->where(['title' => 'beta'])->first(); // Test unauthorized access to admin API $response = $this->actingAs($user)->post("/api/v4/users/{$user->id}/skus/beta", []); $response->assertStatus(403); // For now we allow only the beta sku $response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/skus/mailbox", []); $response->assertStatus(404); $entitlements = $user->entitlements()->where('sku_id', $sku->id)->get(); $this->assertCount(0, $entitlements); // Test adding the beta sku $response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/skus/beta", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("The subscription added successfully.", $json['message']); $this->assertSame(0, $json['sku']['cost']); $this->assertSame($sku->id, $json['sku']['id']); $this->assertSame($sku->name, $json['sku']['name']); $this->assertCount(3, $json); $entitlements = $user->entitlements()->where('sku_id', $sku->id)->get(); $this->assertCount(1, $entitlements); // Test adding the beta sku again, expect an error $response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/skus/beta", []); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame("The subscription already exists.", $json['message']); $this->assertCount(2, $json); $entitlements = $user->entitlements()->where('sku_id', $sku->id)->get(); $this->assertCount(1, $entitlements); } /** * Test user creation (POST /api/v4/users) */ public function testStore(): void { $admin = $this->getTestUser('jeroen@jeroen.jeroen'); // The end-point does not exist $response = $this->actingAs($admin)->post("/api/v4/users", []); $response->assertStatus(404); } /** * Test user suspending (POST /api/v4/users//suspend) */ public function testSuspend(): void { Queue::fake(); // disable jobs $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); // Test unauthorized access to admin API $response = $this->actingAs($user)->post("/api/v4/users/{$user->id}/suspend", []); $response->assertStatus(403); $this->assertFalse($user->isSuspended()); // Test suspending the user $response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/suspend", []); $response->assertStatus(200); $json = $response->json(); $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()); } /** * Test user un-suspending (POST /api/v4/users//unsuspend) */ public function testUnsuspend(): void { Queue::fake(); // disable jobs $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); // Test unauthorized access to admin API $response = $this->actingAs($user)->post("/api/v4/users/{$user->id}/unsuspend", []); $response->assertStatus(403); $this->assertFalse($user->isSuspended()); $user->suspend(); $this->assertTrue($user->isSuspended()); // Test suspending the user $response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/unsuspend", []); $response->assertStatus(200); $json = $response->json(); $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()); } /** * Test user update (PUT /api/v4/users/) */ public function testUpdate(): void { $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); // Test unauthorized access to admin API $response = $this->actingAs($user)->put("/api/v4/users/{$user->id}", []); $response->assertStatus(403); // Test updatig the user data (empty data) $response = $this->actingAs($admin)->put("/api/v4/users/{$user->id}", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("User data updated successfully.", $json['message']); $this->assertCount(2, $json); // Test error handling $post = ['external_email' => 'aaa']; $response = $this->actingAs($admin)->put("/api/v4/users/{$user->id}", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame("The external email must be a valid email address.", $json['errors']['external_email'][0]); $this->assertCount(2, $json); // Test real update $post = ['external_email' => 'modified@test.com']; $response = $this->actingAs($admin)->put("/api/v4/users/{$user->id}", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("User data updated successfully.", $json['message']); $this->assertCount(2, $json); $this->assertSame('modified@test.com', $user->getSetting('external_email')); } } diff --git a/src/tests/Feature/Controller/Reseller/EventLogTest.php b/src/tests/Feature/Controller/Reseller/EventLogTest.php new file mode 100644 index 00000000..e4dfd1a5 --- /dev/null +++ b/src/tests/Feature/Controller/Reseller/EventLogTest.php @@ -0,0 +1,106 @@ +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 index 19386f86..5fa819f1 100644 --- a/src/tests/Feature/DomainTest.php +++ b/src/tests/Feature/DomainTest.php @@ -1,400 +1,424 @@ domains as $domain) { $this->deleteTestDomain($domain); } $this->deleteTestUser('user@gmail.com'); } /** * {@inheritDoc} */ public function tearDown(): void { foreach ($this->domains as $domain) { $this->deleteTestDomain($domain); } $this->deleteTestUser('user@gmail.com'); parent::tearDown(); } /** * Test domain create/creating observer */ public function testCreate(): void { Queue::fake(); $domain = Domain::create([ 'namespace' => 'GMAIL.COM', 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); $result = Domain::where('namespace', 'gmail.com')->first(); $this->assertSame('gmail.com', $result->namespace); $this->assertSame($domain->id, $result->id); $this->assertSame($domain->type, $result->type); $this->assertSame(Domain::STATUS_NEW, $result->status); } /** * Test domain creating jobs */ public function testCreateJobs(): void { // Fake the queue, assert that no jobs were pushed... Queue::fake(); Queue::assertNothingPushed(); $domain = Domain::create([ 'namespace' => 'gmail.com', 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); Queue::assertPushed(\App\Jobs\Domain\CreateJob::class, 1); Queue::assertPushed( \App\Jobs\Domain\CreateJob::class, function ($job) use ($domain) { $domainId = TestCase::getObjectProperty($job, 'domainId'); $domainNamespace = TestCase::getObjectProperty($job, 'domainNamespace'); return $domainId === $domain->id && $domainNamespace === $domain->namespace; } ); $job = new \App\Jobs\Domain\CreateJob($domain->id); $job->handle(); } /** * Tests getPublicDomains() method */ public function testGetPublicDomains(): void { $public_domains = Domain::getPublicDomains(); $this->assertNotContains('public-active.com', $public_domains); $queue = Queue::fake(); $domain = Domain::create([ 'namespace' => 'public-active.com', 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); // External domains should not be returned $public_domains = Domain::getPublicDomains(); $this->assertNotContains('public-active.com', $public_domains); $domain->type = Domain::TYPE_PUBLIC; $domain->save(); $public_domains = Domain::getPublicDomains(); $this->assertContains('public-active.com', $public_domains); // Domains of other tenants should not be returned $tenant = Tenant::whereNotIn('id', [\config('app.tenant_id')])->first(); $domain->tenant_id = $tenant->id; $domain->save(); $public_domains = Domain::getPublicDomains(); $this->assertNotContains('public-active.com', $public_domains); } /** * Test domain (ownership) confirmation * * @group dns */ public function testConfirm(): void { /* DNS records for positive and negative tests - kolab.org: ci-success-cname A 212.103.80.148 ci-success-cname MX 10 mx01.kolabnow.com. ci-success-cname TXT "v=spf1 mx -all" kolab-verify.ci-success-cname CNAME 2b719cfa4e1033b1e1e132977ed4fe3e.ci-success-cname ci-failure-cname A 212.103.80.148 ci-failure-cname MX 10 mx01.kolabnow.com. kolab-verify.ci-failure-cname CNAME 2b719cfa4e1033b1e1e132977ed4fe3e.ci-failure-cname ci-success-txt A 212.103.80.148 ci-success-txt MX 10 mx01.kolabnow.com. ci-success-txt TXT "v=spf1 mx -all" ci-success-txt TXT "kolab-verify=de5d04ababb52d52e2519a2f16d11422" ci-failure-txt A 212.103.80.148 ci-failure-txt MX 10 mx01.kolabnow.com. kolab-verify.ci-failure-txt TXT "kolab-verify=de5d04ababb52d52e2519a2f16d11422" ci-failure-none A 212.103.80.148 ci-failure-none MX 10 mx01.kolabnow.com. */ $queue = Queue::fake(); $domain_props = ['status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL]; $domain = $this->getTestDomain('ci-failure-none.kolab.org', $domain_props); $this->assertTrue($domain->confirm() === false); $this->assertFalse($domain->isConfirmed()); $domain = $this->getTestDomain('ci-failure-txt.kolab.org', $domain_props); $this->assertTrue($domain->confirm() === false); $this->assertFalse($domain->isConfirmed()); $domain = $this->getTestDomain('ci-failure-cname.kolab.org', $domain_props); $this->assertTrue($domain->confirm() === false); $this->assertFalse($domain->isConfirmed()); $domain = $this->getTestDomain('ci-success-txt.kolab.org', $domain_props); $this->assertTrue($domain->confirm()); $this->assertTrue($domain->isConfirmed()); $domain = $this->getTestDomain('ci-success-cname.kolab.org', $domain_props); $this->assertTrue($domain->confirm()); $this->assertTrue($domain->isConfirmed()); } /** * Test domain deletion */ public function testDelete(): void { Queue::fake(); $domain = $this->getTestDomain('gmail.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); $domain->delete(); $this->assertTrue($domain->fresh()->trashed()); $this->assertFalse($domain->fresh()->isDeleted()); // Delete the domain for real $job = new \App\Jobs\Domain\DeleteJob($domain->id); $job->handle(); $this->assertTrue(Domain::withTrashed()->where('id', $domain->id)->first()->isDeleted()); $domain->forceDelete(); $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 */ public function testIsEmpty(): void { Queue::fake(); $this->deleteTestUser('user@gmail.com'); $this->deleteTestGroup('group@gmail.com'); $this->deleteTestResource('resource@gmail.com'); $this->deleteTestSharedFolder('folder@gmail.com'); // Empty domain $domain = $this->getTestDomain('gmail.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); $this->assertTrue($domain->isEmpty()); $this->getTestUser('user@gmail.com'); $this->assertFalse($domain->isEmpty()); $this->deleteTestUser('user@gmail.com'); $this->assertTrue($domain->isEmpty()); $this->getTestGroup('group@gmail.com'); $this->assertFalse($domain->isEmpty()); $this->deleteTestGroup('group@gmail.com'); $this->assertTrue($domain->isEmpty()); $this->getTestResource('resource@gmail.com'); $this->assertFalse($domain->isEmpty()); $this->deleteTestResource('resource@gmail.com'); $this->getTestSharedFolder('folder@gmail.com'); $this->assertFalse($domain->isEmpty()); $this->deleteTestSharedFolder('folder@gmail.com'); // TODO: Test with an existing alias, but not other objects in a domain // Empty public domain $domain = Domain::where('namespace', 'libertymail.net')->first(); $this->assertFalse($domain->isEmpty()); } /** * Test domain restoring */ public function testRestore(): void { Queue::fake(); $domain = $this->getTestDomain('gmail.com', [ 'status' => Domain::STATUS_NEW | Domain::STATUS_SUSPENDED | Domain::STATUS_LDAP_READY | Domain::STATUS_CONFIRMED, 'type' => Domain::TYPE_PUBLIC, ]); $user = $this->getTestUser('user@gmail.com'); $sku = \App\Sku::where('title', 'domain-hosting')->first(); $now = \Carbon\Carbon::now(); // Assign two entitlements to the domain, so we can assert that only the // ones deleted last will be restored $ent1 = \App\Entitlement::create([ 'wallet_id' => $user->wallets->first()->id, 'sku_id' => $sku->id, 'cost' => 0, 'entitleable_id' => $domain->id, 'entitleable_type' => Domain::class, ]); $ent2 = \App\Entitlement::create([ 'wallet_id' => $user->wallets->first()->id, 'sku_id' => $sku->id, 'cost' => 0, 'entitleable_id' => $domain->id, 'entitleable_type' => Domain::class, ]); $domain->delete(); $this->assertTrue($domain->fresh()->trashed()); $this->assertFalse($domain->fresh()->isDeleted()); $this->assertTrue($ent1->fresh()->trashed()); $this->assertTrue($ent2->fresh()->trashed()); // Backdate some properties \App\Entitlement::withTrashed()->where('id', $ent2->id)->update(['deleted_at' => $now->subMinutes(2)]); \App\Entitlement::withTrashed()->where('id', $ent1->id)->update(['updated_at' => $now->subMinutes(10)]); Queue::fake(); $domain->restore(); $domain->refresh(); $this->assertFalse($domain->trashed()); $this->assertFalse($domain->isDeleted()); $this->assertFalse($domain->isSuspended()); $this->assertFalse($domain->isLdapReady()); $this->assertFalse($domain->isActive()); $this->assertFalse($domain->isConfirmed()); $this->assertTrue($domain->isNew()); // Assert entitlements $this->assertTrue($ent2->fresh()->trashed()); $this->assertFalse($ent1->fresh()->trashed()); $this->assertTrue($ent1->updated_at->greaterThan(\Carbon\Carbon::now()->subSeconds(5))); // We expect only one CreateJob and one UpdateJob // Because how Illuminate/Database/Eloquent/SoftDeletes::restore() method // is implemented we cannot skip the UpdateJob in any way. // I don't want to overwrite this method, the extra job shouldn't do any harm. $this->assertCount(2, Queue::pushedJobs()); // @phpstan-ignore-line Queue::assertPushed(\App\Jobs\Domain\UpdateJob::class, 1); Queue::assertPushed(\App\Jobs\Domain\CreateJob::class, 1); Queue::assertPushed( \App\Jobs\Domain\CreateJob::class, function ($job) use ($domain) { return $domain->id === TestCase::getObjectProperty($job, 'domainId'); } ); } /** * Tests for Domain::walletOwner() (from EntitleableTrait) */ public function testWalletOwner(): void { $domain = $this->getTestDomain('kolab.org'); $john = $this->getTestUser('john@kolab.org'); $this->assertSame($john->id, $domain->walletOwner()->id); // A domain without an owner $domain = $this->getTestDomain('gmail.com', [ 'status' => Domain::STATUS_NEW | Domain::STATUS_SUSPENDED | Domain::STATUS_LDAP_READY | Domain::STATUS_CONFIRMED, 'type' => Domain::TYPE_PUBLIC, ]); $this->assertSame(null, $domain->walletOwner()); } /** * Test domain verifying */ public function testVerify(): void { Queue::fake(); // A domain with DNS records $domain = $this->getTestDomain('gmail.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); $this->assertTrue($domain->verify()); $this->assertTrue($domain->isVerified()); // A domain without DNS records $domain = $this->getTestDomain('public-active.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); $this->assertFalse($domain->verify()); $this->assertFalse($domain->isVerified()); } } diff --git a/src/tests/Feature/GroupTest.php b/src/tests/Feature/GroupTest.php index d87d5bf7..cbbd2c35 100644 --- a/src/tests/Feature/GroupTest.php +++ b/src/tests/Feature/GroupTest.php @@ -1,404 +1,425 @@ deleteTestUser('user-test@kolabnow.com'); $this->deleteTestGroup('group-test@kolabnow.com'); } public function tearDown(): void { $this->deleteTestUser('user-test@kolabnow.com'); $this->deleteTestGroup('group-test@kolabnow.com'); parent::tearDown(); } /** * Tests for Group::assignToWallet() */ public function testAssignToWallet(): void { $user = $this->getTestUser('user-test@kolabnow.com'); $group = $this->getTestGroup('group-test@kolabnow.com'); $result = $group->assignToWallet($user->wallets->first()); $this->assertSame($group, $result); $this->assertSame(1, $group->entitlements()->count()); // Can't be done twice on the same group $this->expectException(\Exception::class); $result->assignToWallet($user->wallets->first()); } /** * Test Group::getConfig() and setConfig() methods */ public function testConfigTrait(): void { $group = $this->getTestGroup('group-test@kolabnow.com'); $group->setSetting('sender_policy', '["test","-"]'); $this->assertSame(['sender_policy' => ['test']], $group->getConfig()); $result = $group->setConfig(['sender_policy' => [], 'unknown' => false]); $this->assertSame(['sender_policy' => []], $group->getConfig()); $this->assertSame('[]', $group->getSetting('sender_policy')); $this->assertSame(['unknown' => "The requested configuration parameter is not supported."], $result); $result = $group->setConfig(['sender_policy' => ['test']]); $this->assertSame(['sender_policy' => ['test']], $group->getConfig()); $this->assertSame('["test","-"]', $group->getSetting('sender_policy')); $this->assertSame([], $result); } /** * Test creating a group */ public function testCreate(): void { Queue::fake(); $group = Group::create(['email' => 'GROUP-test@kolabnow.com']); $this->assertSame('group-test@kolabnow.com', $group->email); $this->assertSame('group-test', $group->name); $this->assertMatchesRegularExpression('/^[0-9]{1,20}$/', $group->id); $this->assertSame([], $group->members); $this->assertTrue($group->isNew()); $this->assertFalse($group->isActive()); Queue::assertPushed( \App\Jobs\Group\CreateJob::class, function ($job) use ($group) { $groupEmail = TestCase::getObjectProperty($job, 'groupEmail'); $groupId = TestCase::getObjectProperty($job, 'groupId'); return $groupEmail === $group->email && $groupId === $group->id; } ); } /** * Test group deletion and force-deletion */ public function testDelete(): void { Queue::fake(); $user = $this->getTestUser('user-test@kolabnow.com'); $group = $this->getTestGroup('group-test@kolabnow.com'); $group->assignToWallet($user->wallets->first()); $entitlements = \App\Entitlement::where('entitleable_id', $group->id); $this->assertSame(1, $entitlements->count()); $group->delete(); $this->assertTrue($group->fresh()->trashed()); $this->assertSame(0, $entitlements->count()); $this->assertSame(1, $entitlements->withTrashed()->count()); $group->forceDelete(); $this->assertSame(0, $entitlements->withTrashed()->count()); $this->assertCount(0, Group::withTrashed()->where('id', $group->id)->get()); Queue::assertPushed(\App\Jobs\Group\DeleteJob::class, 1); Queue::assertPushed( \App\Jobs\Group\DeleteJob::class, function ($job) use ($group) { $groupEmail = TestCase::getObjectProperty($job, 'groupEmail'); $groupId = TestCase::getObjectProperty($job, 'groupId'); return $groupEmail === $group->email && $groupId === $group->id; } ); } + /** + * 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() */ public function testEmailExists(): void { Queue::fake(); $group = $this->getTestGroup('group-test@kolabnow.com'); $this->assertFalse(Group::emailExists('unknown@domain.tld')); $this->assertTrue(Group::emailExists($group->email)); $result = Group::emailExists($group->email, true); $this->assertSame($result->id, $group->id); $group->delete(); $this->assertTrue(Group::emailExists($group->email)); $result = Group::emailExists($group->email, true); $this->assertSame($result->id, $group->id); } /* * Test group restoring */ public function testRestore(): void { Queue::fake(); $user = $this->getTestUser('user-test@kolabnow.com'); $group = $this->getTestGroup('group-test@kolabnow.com', [ 'status' => Group::STATUS_ACTIVE | Group::STATUS_LDAP_READY | Group::STATUS_SUSPENDED, ]); $group->assignToWallet($user->wallets->first()); $entitlements = \App\Entitlement::where('entitleable_id', $group->id); $this->assertTrue($group->isSuspended()); $this->assertTrue($group->isLdapReady()); $this->assertTrue($group->isActive()); $this->assertSame(1, $entitlements->count()); $group->delete(); $this->assertTrue($group->fresh()->trashed()); $this->assertSame(0, $entitlements->count()); $this->assertSame(1, $entitlements->withTrashed()->count()); Queue::fake(); $group->restore(); $group->refresh(); $this->assertFalse($group->trashed()); $this->assertFalse($group->isDeleted()); $this->assertFalse($group->isSuspended()); $this->assertFalse($group->isLdapReady()); $this->assertFalse($group->isActive()); $this->assertTrue($group->isNew()); $this->assertSame(1, $entitlements->count()); $entitlements->get()->each(function ($ent) { $this->assertTrue($ent->updated_at->greaterThan(\Carbon\Carbon::now()->subSeconds(5))); }); Queue::assertPushed(\App\Jobs\Group\CreateJob::class, 1); Queue::assertPushed( \App\Jobs\Group\CreateJob::class, function ($job) use ($group) { $groupEmail = TestCase::getObjectProperty($job, 'groupEmail'); $groupId = TestCase::getObjectProperty($job, 'groupId'); return $groupEmail === $group->email && $groupId === $group->id; } ); } /** * Tests for GroupSettingsTrait functionality and GroupSettingObserver */ public function testSettings(): void { Queue::fake(); Queue::assertNothingPushed(); $group = $this->getTestGroup('group-test@kolabnow.com'); Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 0); // Add a setting $group->setSetting('unknown', 'test'); Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 0); // Add a setting that is synced to LDAP $group->setSetting('sender_policy', '[]'); Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 1); // Note: We test both current group as well as fresh group object // to make sure cache works as expected $this->assertSame('test', $group->getSetting('unknown')); $this->assertSame('[]', $group->fresh()->getSetting('sender_policy')); Queue::fake(); // Update a setting $group->setSetting('unknown', 'test1'); Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 0); // Update a setting that is synced to LDAP $group->setSetting('sender_policy', '["-"]'); Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 1); $this->assertSame('test1', $group->getSetting('unknown')); $this->assertSame('["-"]', $group->fresh()->getSetting('sender_policy')); Queue::fake(); // Delete a setting (null) $group->setSetting('unknown', null); Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 0); // Delete a setting that is synced to LDAP $group->setSetting('sender_policy', null); Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 1); $this->assertSame(null, $group->getSetting('unknown')); $this->assertSame(null, $group->fresh()->getSetting('sender_policy')); } /** * Test group status assignment and is*() methods */ public function testStatus(): void { $group = new Group(); $this->assertSame(false, $group->isNew()); $this->assertSame(false, $group->isActive()); $this->assertSame(false, $group->isDeleted()); $this->assertSame(false, $group->isLdapReady()); $this->assertSame(false, $group->isSuspended()); $group->status = Group::STATUS_NEW; $this->assertSame(true, $group->isNew()); $this->assertSame(false, $group->isActive()); $this->assertSame(false, $group->isDeleted()); $this->assertSame(false, $group->isLdapReady()); $this->assertSame(false, $group->isSuspended()); $group->status |= Group::STATUS_ACTIVE; $this->assertSame(true, $group->isNew()); $this->assertSame(true, $group->isActive()); $this->assertSame(false, $group->isDeleted()); $this->assertSame(false, $group->isLdapReady()); $this->assertSame(false, $group->isSuspended()); $group->status |= Group::STATUS_LDAP_READY; $this->assertSame(true, $group->isNew()); $this->assertSame(true, $group->isActive()); $this->assertSame(false, $group->isDeleted()); $this->assertSame(true, $group->isLdapReady()); $this->assertSame(false, $group->isSuspended()); $group->status |= Group::STATUS_DELETED; $this->assertSame(true, $group->isNew()); $this->assertSame(true, $group->isActive()); $this->assertSame(true, $group->isDeleted()); $this->assertSame(true, $group->isLdapReady()); $this->assertSame(false, $group->isSuspended()); $group->status |= Group::STATUS_SUSPENDED; $this->assertSame(true, $group->isNew()); $this->assertSame(true, $group->isActive()); $this->assertSame(true, $group->isDeleted()); $this->assertSame(true, $group->isLdapReady()); $this->assertSame(true, $group->isSuspended()); // Unknown status value $this->expectException(\Exception::class); $group->status = 111; } /** * Tests for Group::suspend() */ public function testSuspend(): void { Queue::fake(); $group = $this->getTestGroup('group-test@kolabnow.com'); $group->suspend(); $this->assertTrue($group->isSuspended()); Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 1); Queue::assertPushed( \App\Jobs\Group\UpdateJob::class, function ($job) use ($group) { $groupEmail = TestCase::getObjectProperty($job, 'groupEmail'); $groupId = TestCase::getObjectProperty($job, 'groupId'); return $groupEmail === $group->email && $groupId === $group->id; } ); } /** * Test updating a group */ public function testUpdate(): void { Queue::fake(); $group = $this->getTestGroup('group-test@kolabnow.com'); $group->status |= Group::STATUS_DELETED; $group->save(); Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 1); Queue::assertPushed( \App\Jobs\Group\UpdateJob::class, function ($job) use ($group) { $groupEmail = TestCase::getObjectProperty($job, 'groupEmail'); $groupId = TestCase::getObjectProperty($job, 'groupId'); return $groupEmail === $group->email && $groupId === $group->id; } ); } /** * Tests for Group::unsuspend() */ public function testUnsuspend(): void { Queue::fake(); $group = $this->getTestGroup('group-test@kolabnow.com'); $group->status = Group::STATUS_SUSPENDED; $group->unsuspend(); $this->assertFalse($group->isSuspended()); Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 1); Queue::assertPushed( \App\Jobs\Group\UpdateJob::class, function ($job) use ($group) { $groupEmail = TestCase::getObjectProperty($job, 'groupEmail'); $groupId = TestCase::getObjectProperty($job, 'groupId'); return $groupEmail === $group->email && $groupId === $group->id; } ); } } diff --git a/src/tests/Feature/UserTest.php b/src/tests/Feature/UserTest.php index 7f8791bb..0349e2eb 100644 --- a/src/tests/Feature/UserTest.php +++ b/src/tests/Feature/UserTest.php @@ -1,1557 +1,1578 @@ deleteTestUser('user-test@' . \config('app.domain')); $this->deleteTestUser('UserAccountA@UserAccount.com'); $this->deleteTestUser('UserAccountB@UserAccount.com'); $this->deleteTestUser('UserAccountC@UserAccount.com'); $this->deleteTestGroup('test-group@UserAccount.com'); $this->deleteTestResource('test-resource@UserAccount.com'); $this->deleteTestSharedFolder('test-folder@UserAccount.com'); $this->deleteTestDomain('UserAccount.com'); $this->deleteTestDomain('UserAccountAdd.com'); Package::where('title', 'test-package')->delete(); } /** * {@inheritDoc} */ public function tearDown(): void { \App\TenantSetting::truncate(); Package::where('title', 'test-package')->delete(); $this->deleteTestUser('user-test@' . \config('app.domain')); $this->deleteTestUser('UserAccountA@UserAccount.com'); $this->deleteTestUser('UserAccountB@UserAccount.com'); $this->deleteTestUser('UserAccountC@UserAccount.com'); $this->deleteTestGroup('test-group@UserAccount.com'); $this->deleteTestResource('test-resource@UserAccount.com'); $this->deleteTestSharedFolder('test-folder@UserAccount.com'); $this->deleteTestDomain('UserAccount.com'); $this->deleteTestDomain('UserAccountAdd.com'); parent::tearDown(); } /** * Tests for User::assignPackage() */ public function testAssignPackage(): void { $user = $this->getTestUser('user-test@' . \config('app.domain')); $wallet = $user->wallets()->first(); $skuGroupware = Sku::withEnvTenantContext()->where('title', 'groupware')->first(); // cost: 490 $skuMailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); // cost: 500 $skuStorage = Sku::withEnvTenantContext()->where('title', 'storage')->first(); // cost: 25 $package = Package::create([ 'title' => 'test-package', 'name' => 'Test Account', 'description' => 'Test account.', 'discount_rate' => 0, ]); // WARNING: saveMany() sets package_skus.cost = skus.cost $package->skus()->saveMany([ $skuMailbox, $skuGroupware, $skuStorage ]); $package->skus()->updateExistingPivot($skuStorage, ['qty' => 2, 'cost' => null], false); $package->skus()->updateExistingPivot($skuMailbox, ['cost' => null], false); $package->skus()->updateExistingPivot($skuGroupware, ['cost' => 100], false); $user->assignPackage($package); $this->assertCount(4, $user->entitlements()->get()); // mailbox + groupware + 2 x storage $entitlement = $wallet->entitlements()->where('sku_id', $skuMailbox->id)->first(); $this->assertSame($skuMailbox->id, $entitlement->sku->id); $this->assertSame($wallet->id, $entitlement->wallet->id); $this->assertEquals($user->id, $entitlement->entitleable_id); $this->assertTrue($entitlement->entitleable instanceof \App\User); $this->assertSame($skuMailbox->cost, $entitlement->cost); $entitlement = $wallet->entitlements()->where('sku_id', $skuGroupware->id)->first(); $this->assertSame($skuGroupware->id, $entitlement->sku->id); $this->assertSame($wallet->id, $entitlement->wallet->id); $this->assertEquals($user->id, $entitlement->entitleable_id); $this->assertTrue($entitlement->entitleable instanceof \App\User); $this->assertSame(100, $entitlement->cost); $entitlement = $wallet->entitlements()->where('sku_id', $skuStorage->id)->first(); $this->assertSame($skuStorage->id, $entitlement->sku->id); $this->assertSame($wallet->id, $entitlement->wallet->id); $this->assertEquals($user->id, $entitlement->entitleable_id); $this->assertTrue($entitlement->entitleable instanceof \App\User); $this->assertSame(0, $entitlement->cost); } /** * Tests for User::assignPlan() */ public function testAssignPlan(): void { $this->markTestIncomplete(); } /** * Tests for User::assignSku() */ public function testAssignSku(): void { $user = $this->getTestUser('user-test@' . \config('app.domain')); $wallet = $user->wallets()->first(); $skuStorage = Sku::withEnvTenantContext()->where('title', 'storage')->first(); $skuMailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); $user->assignSku($skuMailbox); $this->assertCount(1, $user->entitlements()->get()); $entitlement = $wallet->entitlements()->where('sku_id', $skuMailbox->id)->first(); $this->assertSame($skuMailbox->id, $entitlement->sku->id); $this->assertSame($wallet->id, $entitlement->wallet->id); $this->assertEquals($user->id, $entitlement->entitleable_id); $this->assertTrue($entitlement->entitleable instanceof \App\User); $this->assertSame($skuMailbox->cost, $entitlement->cost); // Test units_free handling for ($x = 0; $x < 5; $x++) { $user->assignSku($skuStorage); } $entitlements = $user->entitlements()->where('sku_id', $skuStorage->id) ->where('cost', 0) ->get(); $this->assertCount(5, $entitlements); $user->assignSku($skuStorage); $entitlements = $user->entitlements()->where('sku_id', $skuStorage->id) ->where('cost', $skuStorage->cost) ->get(); $this->assertCount(1, $entitlements); } /** * Verify a wallet assigned a controller is among the accounts of the assignee. */ public function testAccounts(): void { $userA = $this->getTestUser('UserAccountA@UserAccount.com'); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $this->assertTrue($userA->wallets()->count() == 1); $userA->wallets()->each( function ($wallet) use ($userB) { $wallet->addController($userB); } ); $this->assertTrue($userB->accounts()->get()[0]->id === $userA->wallets()->get()[0]->id); } /** * Test User::canDelete() method */ public function testCanDelete(): void { $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $domain = $this->getTestDomain('kolab.org'); // Admin $this->assertTrue($admin->canDelete($admin)); $this->assertFalse($admin->canDelete($john)); $this->assertFalse($admin->canDelete($jack)); $this->assertFalse($admin->canDelete($reseller1)); $this->assertFalse($admin->canDelete($domain)); $this->assertFalse($admin->canDelete($domain->wallet())); // Reseller - kolabnow $this->assertFalse($reseller1->canDelete($john)); $this->assertFalse($reseller1->canDelete($jack)); $this->assertTrue($reseller1->canDelete($reseller1)); $this->assertFalse($reseller1->canDelete($domain)); $this->assertFalse($reseller1->canDelete($domain->wallet())); $this->assertFalse($reseller1->canDelete($admin)); // Normal user - account owner $this->assertTrue($john->canDelete($john)); $this->assertTrue($john->canDelete($ned)); $this->assertTrue($john->canDelete($jack)); $this->assertTrue($john->canDelete($domain)); $this->assertFalse($john->canDelete($domain->wallet())); $this->assertFalse($john->canDelete($reseller1)); $this->assertFalse($john->canDelete($admin)); // Normal user - a non-owner and non-controller $this->assertFalse($jack->canDelete($jack)); $this->assertFalse($jack->canDelete($john)); $this->assertFalse($jack->canDelete($domain)); $this->assertFalse($jack->canDelete($domain->wallet())); $this->assertFalse($jack->canDelete($reseller1)); $this->assertFalse($jack->canDelete($admin)); // Normal user - John's wallet controller $this->assertTrue($ned->canDelete($ned)); $this->assertTrue($ned->canDelete($john)); $this->assertTrue($ned->canDelete($jack)); $this->assertTrue($ned->canDelete($domain)); $this->assertFalse($ned->canDelete($domain->wallet())); $this->assertFalse($ned->canDelete($reseller1)); $this->assertFalse($ned->canDelete($admin)); } /** * Test User::canRead() method */ public function testCanRead(): void { $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $domain = $this->getTestDomain('kolab.org'); // Admin $this->assertTrue($admin->canRead($admin)); $this->assertTrue($admin->canRead($john)); $this->assertTrue($admin->canRead($jack)); $this->assertTrue($admin->canRead($reseller1)); $this->assertTrue($admin->canRead($reseller2)); $this->assertTrue($admin->canRead($domain)); $this->assertTrue($admin->canRead($domain->wallet())); // Reseller - kolabnow $this->assertTrue($reseller1->canRead($john)); $this->assertTrue($reseller1->canRead($jack)); $this->assertTrue($reseller1->canRead($reseller1)); $this->assertTrue($reseller1->canRead($domain)); $this->assertTrue($reseller1->canRead($domain->wallet())); $this->assertFalse($reseller1->canRead($reseller2)); $this->assertFalse($reseller1->canRead($admin)); // Reseller - different tenant $this->assertTrue($reseller2->canRead($reseller2)); $this->assertFalse($reseller2->canRead($john)); $this->assertFalse($reseller2->canRead($jack)); $this->assertFalse($reseller2->canRead($reseller1)); $this->assertFalse($reseller2->canRead($domain)); $this->assertFalse($reseller2->canRead($domain->wallet())); $this->assertFalse($reseller2->canRead($admin)); // Normal user - account owner $this->assertTrue($john->canRead($john)); $this->assertTrue($john->canRead($ned)); $this->assertTrue($john->canRead($jack)); $this->assertTrue($john->canRead($domain)); $this->assertTrue($john->canRead($domain->wallet())); $this->assertFalse($john->canRead($reseller1)); $this->assertFalse($john->canRead($reseller2)); $this->assertFalse($john->canRead($admin)); // Normal user - a non-owner and non-controller $this->assertTrue($jack->canRead($jack)); $this->assertFalse($jack->canRead($john)); $this->assertFalse($jack->canRead($domain)); $this->assertFalse($jack->canRead($domain->wallet())); $this->assertFalse($jack->canRead($reseller1)); $this->assertFalse($jack->canRead($reseller2)); $this->assertFalse($jack->canRead($admin)); // Normal user - John's wallet controller $this->assertTrue($ned->canRead($ned)); $this->assertTrue($ned->canRead($john)); $this->assertTrue($ned->canRead($jack)); $this->assertTrue($ned->canRead($domain)); $this->assertTrue($ned->canRead($domain->wallet())); $this->assertFalse($ned->canRead($reseller1)); $this->assertFalse($ned->canRead($reseller2)); $this->assertFalse($ned->canRead($admin)); } /** * Test User::canUpdate() method */ public function testCanUpdate(): void { $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $domain = $this->getTestDomain('kolab.org'); // Admin $this->assertTrue($admin->canUpdate($admin)); $this->assertTrue($admin->canUpdate($john)); $this->assertTrue($admin->canUpdate($jack)); $this->assertTrue($admin->canUpdate($reseller1)); $this->assertTrue($admin->canUpdate($reseller2)); $this->assertTrue($admin->canUpdate($domain)); $this->assertTrue($admin->canUpdate($domain->wallet())); // Reseller - kolabnow $this->assertTrue($reseller1->canUpdate($john)); $this->assertTrue($reseller1->canUpdate($jack)); $this->assertTrue($reseller1->canUpdate($reseller1)); $this->assertTrue($reseller1->canUpdate($domain)); $this->assertTrue($reseller1->canUpdate($domain->wallet())); $this->assertFalse($reseller1->canUpdate($reseller2)); $this->assertFalse($reseller1->canUpdate($admin)); // Reseller - different tenant $this->assertTrue($reseller2->canUpdate($reseller2)); $this->assertFalse($reseller2->canUpdate($john)); $this->assertFalse($reseller2->canUpdate($jack)); $this->assertFalse($reseller2->canUpdate($reseller1)); $this->assertFalse($reseller2->canUpdate($domain)); $this->assertFalse($reseller2->canUpdate($domain->wallet())); $this->assertFalse($reseller2->canUpdate($admin)); // Normal user - account owner $this->assertTrue($john->canUpdate($john)); $this->assertTrue($john->canUpdate($ned)); $this->assertTrue($john->canUpdate($jack)); $this->assertTrue($john->canUpdate($domain)); $this->assertFalse($john->canUpdate($domain->wallet())); $this->assertFalse($john->canUpdate($reseller1)); $this->assertFalse($john->canUpdate($reseller2)); $this->assertFalse($john->canUpdate($admin)); // Normal user - a non-owner and non-controller $this->assertTrue($jack->canUpdate($jack)); $this->assertFalse($jack->canUpdate($john)); $this->assertFalse($jack->canUpdate($domain)); $this->assertFalse($jack->canUpdate($domain->wallet())); $this->assertFalse($jack->canUpdate($reseller1)); $this->assertFalse($jack->canUpdate($reseller2)); $this->assertFalse($jack->canUpdate($admin)); // Normal user - John's wallet controller $this->assertTrue($ned->canUpdate($ned)); $this->assertTrue($ned->canUpdate($john)); $this->assertTrue($ned->canUpdate($jack)); $this->assertTrue($ned->canUpdate($domain)); $this->assertFalse($ned->canUpdate($domain->wallet())); $this->assertFalse($ned->canUpdate($reseller1)); $this->assertFalse($ned->canUpdate($reseller2)); $this->assertFalse($ned->canUpdate($admin)); } /** * Test user created/creating/updated observers */ public function testCreateAndUpdate(): void { Queue::fake(); $domain = \config('app.domain'); \App\Tenant::find(\config('app.tenant_id'))->setSetting('pgp.enable', 0); $user = User::create([ 'email' => 'USER-test@' . \strtoupper($domain), 'password' => 'test', ]); $result = User::where('email', "user-test@$domain")->first(); $this->assertSame("user-test@$domain", $result->email); $this->assertSame($user->id, $result->id); $this->assertSame(User::STATUS_NEW, $result->status); $this->assertSame(0, $user->passwords()->count()); Queue::assertPushed(\App\Jobs\User\CreateJob::class, 1); Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 0); Queue::assertPushed( \App\Jobs\User\CreateJob::class, function ($job) use ($user) { $userEmail = TestCase::getObjectProperty($job, 'userEmail'); $userId = TestCase::getObjectProperty($job, 'userId'); return $userEmail === $user->email && $userId === $user->id; } ); // Test invoking KeyCreateJob $this->deleteTestUser("user-test@$domain"); \App\Tenant::find(\config('app.tenant_id'))->setSetting('pgp.enable', 1); $user = User::create(['email' => "user-test@$domain", 'password' => 'test']); Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 1); Queue::assertPushed( \App\Jobs\PGP\KeyCreateJob::class, function ($job) use ($user) { $userEmail = TestCase::getObjectProperty($job, 'userEmail'); $userId = TestCase::getObjectProperty($job, 'userId'); return $userEmail === $user->email && $userId === $user->id; } ); // Update the user, test the password change $user->setSetting('password_expiration_warning', '2020-10-10 10:10:10'); $oldPassword = $user->password; $user->password = 'test123'; $user->save(); $this->assertNotEquals($oldPassword, $user->password); $this->assertSame(0, $user->passwords()->count()); $this->assertNull($user->getSetting('password_expiration_warning')); $this->assertMatchesRegularExpression( '/^' . now()->format('Y-m-d') . ' [0-9]{2}:[0-9]{2}:[0-9]{2}$/', $user->getSetting('password_update') ); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); Queue::assertPushed( \App\Jobs\User\UpdateJob::class, function ($job) use ($user) { $userEmail = TestCase::getObjectProperty($job, 'userEmail'); $userId = TestCase::getObjectProperty($job, 'userId'); return $userEmail === $user->email && $userId === $user->id; } ); // Update the user, test the password history $user->setSetting('password_policy', 'last:3'); $oldPassword = $user->password; $user->password = 'test1234'; $user->save(); $this->assertSame(1, $user->passwords()->count()); $this->assertSame($oldPassword, $user->passwords()->first()->password); $user->password = 'test12345'; $user->save(); $oldPassword = $user->password; $user->password = 'test123456'; $user->save(); $this->assertSame(2, $user->passwords()->count()); $this->assertSame($oldPassword, $user->passwords()->latest()->first()->password); } /** * Tests for User::domains() */ public function testDomains(): void { $user = $this->getTestUser('john@kolab.org'); $domain = $this->getTestDomain('useraccount.com', [ 'status' => Domain::STATUS_NEW | Domain::STATUS_ACTIVE, 'type' => Domain::TYPE_PUBLIC, ]); $domains = $user->domains()->pluck('namespace')->all(); $this->assertContains($domain->namespace, $domains); $this->assertContains('kolab.org', $domains); // Jack is not the wallet controller, so for him the list should not // include John's domains, kolab.org specifically $user = $this->getTestUser('jack@kolab.org'); $domains = $user->domains()->pluck('namespace')->all(); $this->assertContains($domain->namespace, $domains); $this->assertNotContains('kolab.org', $domains); // Public domains of other tenants should not be returned $tenant = \App\Tenant::where('id', '!=', \config('app.tenant_id'))->first(); $domain->tenant_id = $tenant->id; $domain->save(); $domains = $user->domains()->pluck('namespace')->all(); $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 */ public function testDegradeAndUndegrade(): void { Queue::fake(); // Test an account with users, domain $userA = $this->getTestUser('UserAccountA@UserAccount.com'); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $domain = $this->getTestDomain('UserAccount.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_HOSTED, ]); $userA->assignPackage($package_kolab); $domain->assignPackage($package_domain, $userA); $userA->assignPackage($package_kolab, $userB); $entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id); $entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id); $entitlementsDomain = \App\Entitlement::where('entitleable_id', $domain->id); $yesterday = Carbon::now()->subDays(1); $this->backdateEntitlements($entitlementsA->get(), $yesterday, Carbon::now()->subMonthsWithoutOverflow(1)); $this->backdateEntitlements($entitlementsB->get(), $yesterday, Carbon::now()->subMonthsWithoutOverflow(1)); $wallet = $userA->wallets->first(); $this->assertSame(7, $entitlementsA->count()); $this->assertSame(7, $entitlementsB->count()); $this->assertSame(7, $entitlementsA->whereDate('updated_at', $yesterday->toDateString())->count()); $this->assertSame(7, $entitlementsB->whereDate('updated_at', $yesterday->toDateString())->count()); $this->assertSame(0, $wallet->balance); Queue::fake(); // reset queue state // Degrade the account/wallet owner $userA->degrade(); $entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id); $entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id); $this->assertTrue($userA->fresh()->isDegraded()); $this->assertTrue($userA->fresh()->isDegraded(true)); $this->assertFalse($userB->fresh()->isDegraded()); $this->assertTrue($userB->fresh()->isDegraded(true)); $balance = $wallet->fresh()->balance; $this->assertTrue($balance <= -64); $this->assertSame(7, $entitlementsA->whereDate('updated_at', Carbon::now()->toDateString())->count()); $this->assertSame(7, $entitlementsB->whereDate('updated_at', Carbon::now()->toDateString())->count()); // Expect one update job for every user // @phpstan-ignore-next-line $userIds = Queue::pushed(\App\Jobs\User\UpdateJob::class)->map(function ($job) { return TestCase::getObjectProperty($job, 'userId'); })->all(); $this->assertSame([$userA->id, $userB->id], $userIds); // Un-Degrade the account/wallet owner $entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id); $entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id); $yesterday = Carbon::now()->subDays(1); $this->backdateEntitlements($entitlementsA->get(), $yesterday, Carbon::now()->subMonthsWithoutOverflow(1)); $this->backdateEntitlements($entitlementsB->get(), $yesterday, Carbon::now()->subMonthsWithoutOverflow(1)); Queue::fake(); // reset queue state $userA->undegrade(); $this->assertFalse($userA->fresh()->isDegraded()); $this->assertFalse($userA->fresh()->isDegraded(true)); $this->assertFalse($userB->fresh()->isDegraded()); $this->assertFalse($userB->fresh()->isDegraded(true)); // Expect no balance change, degraded account entitlements are free $this->assertSame($balance, $wallet->fresh()->balance); $this->assertSame(7, $entitlementsA->whereDate('updated_at', Carbon::now()->toDateString())->count()); $this->assertSame(7, $entitlementsB->whereDate('updated_at', Carbon::now()->toDateString())->count()); // Expect one update job for every user // @phpstan-ignore-next-line $userIds = Queue::pushed(\App\Jobs\User\UpdateJob::class)->map(function ($job) { return TestCase::getObjectProperty($job, 'userId'); })->all(); $this->assertSame([$userA->id, $userB->id], $userIds); } /** * Test user deletion */ public function testDelete(): void { Queue::fake(); $user = $this->getTestUser('user-test@' . \config('app.domain')); $package = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $user->assignPackage($package); $id = $user->id; $this->assertCount(7, $user->entitlements()->get()); $user->delete(); $this->assertCount(0, $user->entitlements()->get()); $this->assertTrue($user->fresh()->trashed()); $this->assertFalse($user->fresh()->isDeleted()); // Delete the user for real $job = new \App\Jobs\User\DeleteJob($id); $job->handle(); $this->assertTrue(User::withTrashed()->where('id', $id)->first()->isDeleted()); $user->forceDelete(); $this->assertCount(0, User::withTrashed()->where('id', $id)->get()); // Test an account with users, domain, and group, and resource $userA = $this->getTestUser('UserAccountA@UserAccount.com'); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $userC = $this->getTestUser('UserAccountC@UserAccount.com'); $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $domain = $this->getTestDomain('UserAccount.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_HOSTED, ]); $userA->assignPackage($package_kolab); $domain->assignPackage($package_domain, $userA); $userA->assignPackage($package_kolab, $userB); $userA->assignPackage($package_kolab, $userC); $group = $this->getTestGroup('test-group@UserAccount.com'); $group->assignToWallet($userA->wallets->first()); $resource = $this->getTestResource('test-resource@UserAccount.com', ['name' => 'test']); $resource->assignToWallet($userA->wallets->first()); $folder = $this->getTestSharedFolder('test-folder@UserAccount.com', ['name' => 'test']); $folder->assignToWallet($userA->wallets->first()); $entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id); $entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id); $entitlementsC = \App\Entitlement::where('entitleable_id', $userC->id); $entitlementsDomain = \App\Entitlement::where('entitleable_id', $domain->id); $entitlementsGroup = \App\Entitlement::where('entitleable_id', $group->id); $entitlementsResource = \App\Entitlement::where('entitleable_id', $resource->id); $entitlementsFolder = \App\Entitlement::where('entitleable_id', $folder->id); $this->assertSame(7, $entitlementsA->count()); $this->assertSame(7, $entitlementsB->count()); $this->assertSame(7, $entitlementsC->count()); $this->assertSame(1, $entitlementsDomain->count()); $this->assertSame(1, $entitlementsGroup->count()); $this->assertSame(1, $entitlementsResource->count()); $this->assertSame(1, $entitlementsFolder->count()); // Delete non-controller user $userC->delete(); $this->assertTrue($userC->fresh()->trashed()); $this->assertFalse($userC->fresh()->isDeleted()); $this->assertSame(0, $entitlementsC->count()); // Delete the controller (and expect "sub"-users to be deleted too) $userA->delete(); $this->assertSame(0, $entitlementsA->count()); $this->assertSame(0, $entitlementsB->count()); $this->assertSame(0, $entitlementsDomain->count()); $this->assertSame(0, $entitlementsGroup->count()); $this->assertSame(0, $entitlementsResource->count()); $this->assertSame(0, $entitlementsFolder->count()); $this->assertSame(7, $entitlementsA->withTrashed()->count()); $this->assertSame(7, $entitlementsB->withTrashed()->count()); $this->assertSame(7, $entitlementsC->withTrashed()->count()); $this->assertSame(1, $entitlementsDomain->withTrashed()->count()); $this->assertSame(1, $entitlementsGroup->withTrashed()->count()); $this->assertSame(1, $entitlementsResource->withTrashed()->count()); $this->assertSame(1, $entitlementsFolder->withTrashed()->count()); $this->assertTrue($userA->fresh()->trashed()); $this->assertTrue($userB->fresh()->trashed()); $this->assertTrue($domain->fresh()->trashed()); $this->assertTrue($group->fresh()->trashed()); $this->assertTrue($resource->fresh()->trashed()); $this->assertTrue($folder->fresh()->trashed()); $this->assertFalse($userA->isDeleted()); $this->assertFalse($userB->isDeleted()); $this->assertFalse($domain->isDeleted()); $this->assertFalse($group->isDeleted()); $this->assertFalse($resource->isDeleted()); $this->assertFalse($folder->isDeleted()); $userA->forceDelete(); $all_entitlements = \App\Entitlement::where('wallet_id', $userA->wallets->first()->id); $transactions = \App\Transaction::where('object_id', $userA->wallets->first()->id); $this->assertSame(0, $all_entitlements->withTrashed()->count()); $this->assertSame(0, $transactions->count()); $this->assertCount(0, User::withTrashed()->where('id', $userA->id)->get()); $this->assertCount(0, User::withTrashed()->where('id', $userB->id)->get()); $this->assertCount(0, User::withTrashed()->where('id', $userC->id)->get()); $this->assertCount(0, Domain::withTrashed()->where('id', $domain->id)->get()); $this->assertCount(0, Group::withTrashed()->where('id', $group->id)->get()); $this->assertCount(0, \App\Resource::withTrashed()->where('id', $resource->id)->get()); $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 */ public function testDeleteAndGroups(): void { Queue::fake(); $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $userA = $this->getTestUser('UserAccountA@UserAccount.com'); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $userA->assignPackage($package_kolab, $userB); $group = $this->getTestGroup('test-group@UserAccount.com'); $group->members = ['test@gmail.com', $userB->email]; $group->assignToWallet($userA->wallets->first()); $group->save(); Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 1); $userGroups = $userA->groups()->get(); $this->assertSame(1, $userGroups->count()); $this->assertSame($group->id, $userGroups->first()->id); $userB->delete(); $this->assertSame(['test@gmail.com'], $group->fresh()->members); // Twice, one for save() and one for delete() above Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 2); } /** * Test handling negative balance on user deletion */ public function testDeleteWithNegativeBalance(): void { $user = $this->getTestUser('user-test@' . \config('app.domain')); $wallet = $user->wallets()->first(); $wallet->balance = -1000; $wallet->save(); $reseller_wallet = $user->tenant->wallet(); $reseller_wallet->balance = 0; $reseller_wallet->save(); \App\Transaction::where('object_id', $reseller_wallet->id)->where('object_type', \App\Wallet::class)->delete(); $user->delete(); $reseller_transactions = \App\Transaction::where('object_id', $reseller_wallet->id) ->where('object_type', \App\Wallet::class)->get(); $this->assertSame(-1000, $reseller_wallet->fresh()->balance); $this->assertCount(1, $reseller_transactions); $trans = $reseller_transactions[0]; $this->assertSame("Deleted user {$user->email}", $trans->description); $this->assertSame(-1000, $trans->amount); $this->assertSame(\App\Transaction::WALLET_DEBIT, $trans->type); } /** * Test handling positive balance on user deletion */ public function testDeleteWithPositiveBalance(): void { $user = $this->getTestUser('user-test@' . \config('app.domain')); $wallet = $user->wallets()->first(); $wallet->balance = 1000; $wallet->save(); $reseller_wallet = $user->tenant->wallet(); $reseller_wallet->balance = 0; $reseller_wallet->save(); $user->delete(); $this->assertSame(0, $reseller_wallet->fresh()->balance); } /** * Test user deletion with PGP/WOAT enabled */ public function testDeleteWithPGP(): void { Queue::fake(); // Test with PGP disabled $user = $this->getTestUser('user-test@' . \config('app.domain')); $user->tenant->setSetting('pgp.enable', 0); $user->delete(); Queue::assertPushed(\App\Jobs\PGP\KeyDeleteJob::class, 0); // Test with PGP enabled $this->deleteTestUser('user-test@' . \config('app.domain')); $user = $this->getTestUser('user-test@' . \config('app.domain')); $user->tenant->setSetting('pgp.enable', 1); $user->delete(); $user->tenant->setSetting('pgp.enable', 0); Queue::assertPushed(\App\Jobs\PGP\KeyDeleteJob::class, 1); Queue::assertPushed( \App\Jobs\PGP\KeyDeleteJob::class, function ($job) use ($user) { $userId = TestCase::getObjectProperty($job, 'userId'); $userEmail = TestCase::getObjectProperty($job, 'userEmail'); return $userId == $user->id && $userEmail === $user->email; } ); } /** * Test user deletion vs. rooms */ public function testDeleteWithRooms(): void { $this->markTestIncomplete(); } /** * Tests for User::aliasExists() */ public function testAliasExists(): void { $this->assertTrue(User::aliasExists('jack.daniels@kolab.org')); $this->assertFalse(User::aliasExists('j.daniels@kolab.org')); $this->assertFalse(User::aliasExists('john@kolab.org')); } /** * Tests for User::emailExists() */ public function testEmailExists(): void { $this->assertFalse(User::emailExists('jack.daniels@kolab.org')); $this->assertFalse(User::emailExists('j.daniels@kolab.org')); $this->assertTrue(User::emailExists('john@kolab.org')); $user = User::emailExists('john@kolab.org', true); $this->assertSame('john@kolab.org', $user->email); } /** * Tests for User::findByEmail() */ public function testFindByEmail(): void { $user = $this->getTestUser('john@kolab.org'); $result = User::findByEmail('john'); $this->assertNull($result); $result = User::findByEmail('non-existing@email.com'); $this->assertNull($result); $result = User::findByEmail('john@kolab.org'); $this->assertInstanceOf(User::class, $result); $this->assertSame($user->id, $result->id); // Use an alias $result = User::findByEmail('john.doe@kolab.org'); $this->assertInstanceOf(User::class, $result); $this->assertSame($user->id, $result->id); Queue::fake(); // A case where two users have the same alias $ned = $this->getTestUser('ned@kolab.org'); $ned->setAliases(['joe.monster@kolab.org']); $result = User::findByEmail('joe.monster@kolab.org'); $this->assertNull($result); $ned->setAliases([]); // TODO: searching by external email (setting) $this->markTestIncomplete(); } /** * Test User::hasSku() and countEntitlementsBySku() methods */ public function testHasSku(): void { $john = $this->getTestUser('john@kolab.org'); $this->assertTrue($john->hasSku('mailbox')); $this->assertTrue($john->hasSku('storage')); $this->assertFalse($john->hasSku('beta')); $this->assertFalse($john->hasSku('unknown')); $this->assertSame(0, $john->countEntitlementsBySku('unknown')); $this->assertSame(0, $john->countEntitlementsBySku('2fa')); $this->assertSame(1, $john->countEntitlementsBySku('mailbox')); $this->assertSame(5, $john->countEntitlementsBySku('storage')); } /** * Test User::name() */ public function testName(): void { Queue::fake(); $user = $this->getTestUser('user-test@' . \config('app.domain')); $this->assertSame('', $user->name()); $this->assertSame($user->tenant->title . ' User', $user->name(true)); $user->setSetting('first_name', 'First'); $this->assertSame('First', $user->name()); $this->assertSame('First', $user->name(true)); $user->setSetting('last_name', 'Last'); $this->assertSame('First Last', $user->name()); $this->assertSame('First Last', $user->name(true)); } /** * Test resources() method */ public function testResources(): void { $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $resources = $john->resources()->orderBy('email')->get(); $this->assertSame(2, $resources->count()); $this->assertSame('resource-test1@kolab.org', $resources[0]->email); $this->assertSame('resource-test2@kolab.org', $resources[1]->email); $resources = $ned->resources()->orderBy('email')->get(); $this->assertSame(2, $resources->count()); $this->assertSame('resource-test1@kolab.org', $resources[0]->email); $this->assertSame('resource-test2@kolab.org', $resources[1]->email); $resources = $jack->resources()->get(); $this->assertSame(0, $resources->count()); } /** * Test sharedFolders() method */ public function testSharedFolders(): void { $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $folders = $john->sharedFolders()->orderBy('email')->get(); $this->assertSame(2, $folders->count()); $this->assertSame('folder-contact@kolab.org', $folders[0]->email); $this->assertSame('folder-event@kolab.org', $folders[1]->email); $folders = $ned->sharedFolders()->orderBy('email')->get(); $this->assertSame(2, $folders->count()); $this->assertSame('folder-contact@kolab.org', $folders[0]->email); $this->assertSame('folder-event@kolab.org', $folders[1]->email); $folders = $jack->sharedFolders()->get(); $this->assertSame(0, $folders->count()); } /** * Test user restoring */ public function testRestore(): void { Queue::fake(); // Test an account with users and domain $userA = $this->getTestUser('UserAccountA@UserAccount.com', [ 'status' => User::STATUS_LDAP_READY | User::STATUS_IMAP_READY | User::STATUS_SUSPENDED, ]); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $domainA = $this->getTestDomain('UserAccount.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_HOSTED, ]); $domainB = $this->getTestDomain('UserAccountAdd.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_HOSTED, ]); $userA->assignPackage($package_kolab); $domainA->assignPackage($package_domain, $userA); $domainB->assignPackage($package_domain, $userA); $userA->assignPackage($package_kolab, $userB); $storage_sku = \App\Sku::withEnvTenantContext()->where('title', 'storage')->first(); $now = \Carbon\Carbon::now(); $wallet_id = $userA->wallets->first()->id; // add an extra storage entitlement $ent1 = \App\Entitlement::create([ 'wallet_id' => $wallet_id, 'sku_id' => $storage_sku->id, 'cost' => 0, 'entitleable_id' => $userA->id, 'entitleable_type' => User::class, ]); $entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id); $entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id); $entitlementsDomain = \App\Entitlement::where('entitleable_id', $domainA->id); // First delete the user $userA->delete(); $this->assertSame(0, $entitlementsA->count()); $this->assertSame(0, $entitlementsB->count()); $this->assertSame(0, $entitlementsDomain->count()); $this->assertTrue($userA->fresh()->trashed()); $this->assertTrue($userB->fresh()->trashed()); $this->assertTrue($domainA->fresh()->trashed()); $this->assertTrue($domainB->fresh()->trashed()); $this->assertFalse($userA->isDeleted()); $this->assertFalse($userB->isDeleted()); $this->assertFalse($domainA->isDeleted()); // Backdate one storage entitlement (it's not expected to be restored) \App\Entitlement::withTrashed()->where('id', $ent1->id) ->update(['deleted_at' => $now->copy()->subMinutes(2)]); // Backdate entitlements to assert that they were restored with proper updated_at timestamp \App\Entitlement::withTrashed()->where('wallet_id', $wallet_id) ->update(['updated_at' => $now->subMinutes(10)]); Queue::fake(); // Then restore it $userA->restore(); $userA->refresh(); $this->assertFalse($userA->trashed()); $this->assertFalse($userA->isDeleted()); $this->assertFalse($userA->isSuspended()); $this->assertFalse($userA->isLdapReady()); $this->assertFalse($userA->isImapReady()); $this->assertFalse($userA->isActive()); $this->assertTrue($userA->isNew()); $this->assertTrue($userB->fresh()->trashed()); $this->assertTrue($domainB->fresh()->trashed()); $this->assertFalse($domainA->fresh()->trashed()); // Assert entitlements $this->assertSame(7, $entitlementsA->count()); // mailbox + groupware + 5 x storage $this->assertTrue($ent1->fresh()->trashed()); $entitlementsA->get()->each(function ($ent) { $this->assertTrue($ent->updated_at->greaterThan(\Carbon\Carbon::now()->subSeconds(5))); }); // We expect only CreateJob + UpdateJob pair for both user and domain. // Because how Illuminate/Database/Eloquent/SoftDeletes::restore() method // is implemented we cannot skip the UpdateJob in any way. // I don't want to overwrite this method, the extra job shouldn't do any harm. $this->assertCount(4, Queue::pushedJobs()); // @phpstan-ignore-line Queue::assertPushed(\App\Jobs\Domain\UpdateJob::class, 1); Queue::assertPushed(\App\Jobs\Domain\CreateJob::class, 1); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); Queue::assertPushed(\App\Jobs\User\CreateJob::class, 1); Queue::assertPushed( \App\Jobs\User\CreateJob::class, function ($job) use ($userA) { return $userA->id === TestCase::getObjectProperty($job, 'userId'); } ); } /** * Test user account restrict() and unrestrict() */ public function testRestrictAndUnrestrict(): void { Queue::fake(); // Test an account with users, domain $user = $this->getTestUser('UserAccountA@UserAccount.com'); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $domain = $this->getTestDomain('UserAccount.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_HOSTED, ]); $user->assignPackage($package_kolab); $domain->assignPackage($package_domain, $user); $user->assignPackage($package_kolab, $userB); $this->assertFalse($user->isRestricted()); $this->assertFalse($userB->isRestricted()); $user->restrict(); $this->assertTrue($user->fresh()->isRestricted()); $this->assertFalse($userB->fresh()->isRestricted()); Queue::assertPushed( \App\Jobs\User\UpdateJob::class, function ($job) use ($user) { return TestCase::getObjectProperty($job, 'userId') == $user->id; } ); $userB->restrict(); $this->assertTrue($userB->fresh()->isRestricted()); Queue::fake(); // reset queue state $user->refresh(); $user->unrestrict(); $this->assertFalse($user->fresh()->isRestricted()); $this->assertTrue($userB->fresh()->isRestricted()); Queue::assertPushed( \App\Jobs\User\UpdateJob::class, function ($job) use ($user) { return TestCase::getObjectProperty($job, 'userId') == $user->id; } ); Queue::fake(); // reset queue state $user->unrestrict(true); $this->assertFalse($user->fresh()->isRestricted()); $this->assertFalse($userB->fresh()->isRestricted()); Queue::assertPushed( \App\Jobs\User\UpdateJob::class, function ($job) use ($userB) { return TestCase::getObjectProperty($job, 'userId') == $userB->id; } ); } /** * Tests for AliasesTrait::setAliases() */ public function testSetAliases(): void { Queue::fake(); Queue::assertNothingPushed(); $user = $this->getTestUser('UserAccountA@UserAccount.com'); $domain = $this->getTestDomain('UserAccount.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_HOSTED, ]); $this->assertCount(0, $user->aliases->all()); $user->tenant->setSetting('pgp.enable', 1); // Add an alias $user->setAliases(['UserAlias1@UserAccount.com']); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 1); $user->tenant->setSetting('pgp.enable', 0); $aliases = $user->aliases()->get(); $this->assertCount(1, $aliases); $this->assertSame('useralias1@useraccount.com', $aliases[0]['alias']); // Add another alias $user->setAliases(['UserAlias1@UserAccount.com', 'UserAlias2@UserAccount.com']); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 2); Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 1); $aliases = $user->aliases()->orderBy('alias')->get(); $this->assertCount(2, $aliases); $this->assertSame('useralias1@useraccount.com', $aliases[0]->alias); $this->assertSame('useralias2@useraccount.com', $aliases[1]->alias); $user->tenant->setSetting('pgp.enable', 1); // Remove an alias $user->setAliases(['UserAlias1@UserAccount.com']); $user->tenant->setSetting('pgp.enable', 0); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 3); Queue::assertPushed(\App\Jobs\PGP\KeyDeleteJob::class, 1); Queue::assertPushed( \App\Jobs\PGP\KeyDeleteJob::class, function ($job) use ($user) { $userId = TestCase::getObjectProperty($job, 'userId'); $userEmail = TestCase::getObjectProperty($job, 'userEmail'); return $userId == $user->id && $userEmail === 'useralias2@useraccount.com'; } ); $aliases = $user->aliases()->get(); $this->assertCount(1, $aliases); $this->assertSame('useralias1@useraccount.com', $aliases[0]['alias']); // Remove all aliases $user->setAliases([]); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 4); $this->assertCount(0, $user->aliases()->get()); } /** * Tests for suspendAccount() */ public function testSuspendAccount(): void { $user = $this->getTestUser('UserAccountA@UserAccount.com'); $wallet = $user->wallets()->first(); // No entitlements, expect the wallet owner to be suspended anyway $user->suspendAccount(); $this->assertTrue($user->fresh()->isSuspended()); // Add entitlements and more suspendable objects into the wallet $user->unsuspend(); $mailbox_sku = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); $domain_sku = Sku::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $group_sku = Sku::withEnvTenantContext()->where('title', 'group')->first(); $resource_sku = Sku::withEnvTenantContext()->where('title', 'resource')->first(); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $userB->assignSku($mailbox_sku, 1, $wallet); $domain = $this->getTestDomain('UserAccount.com', ['type' => \App\Domain::TYPE_PUBLIC]); $domain->assignSku($domain_sku, 1, $wallet); $group = $this->getTestGroup('test-group@UserAccount.com'); $group->assignSku($group_sku, 1, $wallet); $resource = $this->getTestResource('test-resource@UserAccount.com'); $resource->assignSku($resource_sku, 1, $wallet); $this->assertFalse($user->isSuspended()); $this->assertFalse($userB->isSuspended()); $this->assertFalse($domain->isSuspended()); $this->assertFalse($group->isSuspended()); $this->assertFalse($resource->isSuspended()); $user->suspendAccount(); $this->assertTrue($user->fresh()->isSuspended()); $this->assertTrue($userB->fresh()->isSuspended()); $this->assertTrue($domain->fresh()->isSuspended()); $this->assertTrue($group->fresh()->isSuspended()); $this->assertFalse($resource->fresh()->isSuspended()); } /** * Tests for UserSettingsTrait::setSettings() and getSetting() and getSettings() */ public function testUserSettings(): void { Queue::fake(); Queue::assertNothingPushed(); $user = $this->getTestUser('UserAccountA@UserAccount.com'); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 0); // Test default settings // Note: Technicly this tests UserObserver::created() behavior $all_settings = $user->settings()->orderBy('key')->get(); $this->assertCount(2, $all_settings); $this->assertSame('country', $all_settings[0]->key); $this->assertSame('CH', $all_settings[0]->value); $this->assertSame('currency', $all_settings[1]->key); $this->assertSame('CHF', $all_settings[1]->value); // Add a setting $user->setSetting('first_name', 'Firstname'); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame('Firstname', $user->getSetting('first_name')); $this->assertSame('Firstname', $user->fresh()->getSetting('first_name')); // Update a setting $user->setSetting('first_name', 'Firstname1'); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 2); // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame('Firstname1', $user->getSetting('first_name')); $this->assertSame('Firstname1', $user->fresh()->getSetting('first_name')); // Delete a setting (null) $user->setSetting('first_name', null); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 3); // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame(null, $user->getSetting('first_name')); $this->assertSame(null, $user->fresh()->getSetting('first_name')); // Delete a setting (empty string) $user->setSetting('first_name', 'Firstname1'); $user->setSetting('first_name', ''); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 5); // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame(null, $user->getSetting('first_name')); $this->assertSame(null, $user->fresh()->getSetting('first_name')); // Set multiple settings at once $user->setSettings([ 'first_name' => 'Firstname2', 'last_name' => 'Lastname2', 'country' => null, ]); // TODO: This really should create a single UserUpdate job, not 3 Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 7); // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame('Firstname2', $user->getSetting('first_name')); $this->assertSame('Firstname2', $user->fresh()->getSetting('first_name')); $this->assertSame('Lastname2', $user->getSetting('last_name')); $this->assertSame('Lastname2', $user->fresh()->getSetting('last_name')); $this->assertSame(null, $user->getSetting('country')); $this->assertSame(null, $user->fresh()->getSetting('country')); $all_settings = $user->settings()->orderBy('key')->get(); $this->assertCount(3, $all_settings); // Test getSettings() method $this->assertSame( [ 'first_name' => 'Firstname2', 'last_name' => 'Lastname2', 'unknown' => null, ], $user->getSettings(['first_name', 'last_name', 'unknown']) ); } /** * Tests for User::users() */ public function testUsers(): void { $jack = $this->getTestUser('jack@kolab.org'); $joe = $this->getTestUser('joe@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $wallet = $john->wallets()->first(); $users = $john->users()->orderBy('email')->get(); $this->assertCount(4, $users); $this->assertEquals($jack->id, $users[0]->id); $this->assertEquals($joe->id, $users[1]->id); $this->assertEquals($john->id, $users[2]->id); $this->assertEquals($ned->id, $users[3]->id); $users = $jack->users()->orderBy('email')->get(); $this->assertCount(0, $users); $users = $ned->users()->orderBy('email')->get(); $this->assertCount(4, $users); } /** * Tests for User::walletOwner() (from EntitleableTrait) */ public function testWalletOwner(): void { $jack = $this->getTestUser('jack@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $this->assertSame($john->id, $john->walletOwner()->id); $this->assertSame($john->id, $jack->walletOwner()->id); $this->assertSame($john->id, $ned->walletOwner()->id); // User with no entitlements $user = $this->getTestUser('UserAccountA@UserAccount.com'); $this->assertSame($user->id, $user->walletOwner()->id); } /** * Tests for User::wallets() */ public function testWallets(): void { $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $this->assertSame(1, $john->wallets()->count()); $this->assertCount(1, $john->wallets); $this->assertInstanceOf(\App\Wallet::class, $john->wallets->first()); $this->assertSame(1, $ned->wallets()->count()); $this->assertCount(1, $ned->wallets); $this->assertInstanceOf(\App\Wallet::class, $ned->wallets->first()); } } diff --git a/src/tests/Unit/EventLogTest.php b/src/tests/Unit/EventLogTest.php new file mode 100644 index 00000000..1add59ae --- /dev/null +++ b/src/tests/Unit/EventLogTest.php @@ -0,0 +1,29 @@ +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); + } +}