diff --git a/src/app/Domain.php b/src/app/Domain.php index b9e37da8..56c8a7ae 100644 --- a/src/app/Domain.php +++ b/src/app/Domain.php @@ -1,339 +1,350 @@ wallets()->get()[0]->id; foreach ($package->skus as $sku) { for ($i = $sku->pivot->qty; $i > 0; $i--) { \App\Entitlement::create( [ 'owner_id' => $user->id, 'wallet_id' => $wallet_id, 'sku_id' => $sku->id, 'cost' => $sku->pivot->cost(), 'entitleable_id' => $this->id, 'entitleable_type' => Domain::class ] ); } } return $this; } public function entitlement() { return $this->morphOne('App\Entitlement', 'entitleable'); } /** * Return list of public+active domain names */ public static function getPublicDomains(): array { $where = sprintf('(type & %s) AND (status & %s)', Domain::TYPE_PUBLIC, Domain::STATUS_ACTIVE); return self::whereRaw($where)->get(['namespace'])->map(function ($domain) { return $domain->namespace; })->toArray(); } /** * Returns whether this domain is active. * * @return bool */ public function isActive(): bool { return ($this->status & self::STATUS_ACTIVE) > 0; } /** * Returns whether this domain is confirmed the ownership of. * * @return bool */ public function isConfirmed(): bool { return ($this->status & self::STATUS_CONFIRMED) > 0; } /** * Returns whether this domain is deleted. * * @return bool */ public function isDeleted(): bool { return ($this->status & self::STATUS_DELETED) > 0; } /** * Returns whether this domain is registered with us. * * @return bool */ public function isExternal(): bool { return ($this->type & self::TYPE_EXTERNAL) > 0; } /** * Returns whether this domain is hosted with us. * * @return bool */ public function isHosted(): bool { return ($this->type & self::TYPE_HOSTED) > 0; } /** * Returns whether this domain is new. * * @return bool */ public function isNew(): bool { return ($this->status & self::STATUS_NEW) > 0; } /** * Returns whether this domain is public. * * @return bool */ public function isPublic(): bool { return ($this->type & self::TYPE_PUBLIC) > 0; } /** * Returns whether this domain is registered in LDAP. * * @return bool */ public function isLdapReady(): bool { return ($this->status & self::STATUS_LDAP_READY) > 0; } /** * Returns whether this domain is suspended. * * @return bool */ public function isSuspended(): bool { return ($this->status & self::STATUS_SUSPENDED) > 0; } /** * Returns whether this (external) domain has been verified * to exist in DNS. * * @return bool */ public function isVerified(): bool { return ($this->status & self::STATUS_VERIFIED) > 0; } /** * Domain status mutator * * @throws \Exception */ public function setStatusAttribute($status) { $new_status = 0; $allowed_values = [ self::STATUS_NEW, self::STATUS_ACTIVE, self::STATUS_CONFIRMED, self::STATUS_SUSPENDED, self::STATUS_DELETED, self::STATUS_LDAP_READY, self::STATUS_VERIFIED, ]; foreach ($allowed_values as $value) { if ($status & $value) { $new_status |= $value; $status ^= $value; } } if ($status > 0) { throw new \Exception("Invalid domain status: {$status}"); } $this->attributes['status'] = $new_status; } /** * Ownership verification by checking for a TXT (or CNAME) record * in the domain's DNS (that matches the verification hash). * * @return bool True if verification was successful, false otherwise * @throws \Exception Throws exception on DNS or DB errors */ public function confirm(): bool { if ($this->isConfirmed()) { return true; } $hash = $this->hash(self::HASH_TEXT); $confirmed = false; // Get DNS records and find a matching TXT entry $records = \dns_get_record($this->namespace, DNS_TXT); if ($records === false) { throw new \Exception("Failed to get DNS record for {$this->namespace}"); } foreach ($records as $record) { if ($record['txt'] === $hash) { $confirmed = true; break; } } // Get DNS records and find a matching CNAME entry // Note: some servers resolve every non-existing name // so we need to define left and right side of the CNAME record // i.e.: kolab-verify IN CNAME .domain.tld. if (!$confirmed) { $cname = $this->hash(self::HASH_CODE) . '.' . $this->namespace; $records = \dns_get_record('kolab-verify.' . $this->namespace, DNS_CNAME); if ($records === false) { throw new \Exception("Failed to get DNS record for {$this->namespace}"); } foreach ($records as $records) { if ($records['target'] === $cname) { $confirmed = true; break; } } } if ($confirmed) { $this->status |= Domain::STATUS_CONFIRMED; $this->save(); } return $confirmed; } /** * Generate a verification hash for this domain * * @param int $mod One of: HASH_CNAME, HASH_CODE (Default), HASH_TEXT * * @return string Verification hash */ public function hash($mod = null): string { $cname = 'kolab-verify'; if ($mod === self::HASH_CNAME) { return $cname; } $hash = \md5('hkccp-verify-' . $this->namespace); return $mod === self::HASH_TEXT ? "$cname=$hash" : $hash; } /** * Verify if a domain exists in DNS * * @return bool True if registered, False otherwise * @throws \Exception Throws exception on DNS or DB errors */ public function verify(): bool { if ($this->isVerified()) { return true; } $record = \dns_get_record($this->namespace, DNS_ANY); if ($record === false) { throw new \Exception("Failed to get DNS record for {$this->namespace}"); } if (!empty($record)) { $this->status |= Domain::STATUS_VERIFIED; $this->save(); return true; } return false; } + + /** + * Returns the wallet by which the domain is controlled + * + * @return \App\Wallet A wallet object + */ + public function wallet(): Wallet + { + return $this->entitlement()->first()->wallet; + } } diff --git a/src/app/Entitlement.php b/src/app/Entitlement.php index dd2d4b71..6a56d6c9 100644 --- a/src/app/Entitlement.php +++ b/src/app/Entitlement.php @@ -1,91 +1,103 @@ 'integer', + ]; + /** * Principally entitleable objects such as 'Domain' or 'User'. * * @return mixed */ public function entitleable() { return $this->morphTo(); } /** * The SKU concerned. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function sku() { return $this->belongsTo('App\Sku'); } /** * The owner of this entitlement. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function owner() { return $this->belongsTo('App\User', 'owner_id', 'id'); } /** * The wallet this entitlement is being billed to * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function wallet() { return $this->belongsTo('App\Wallet'); } + + /** + * Cost mutator. Make sure cost is integer. + */ + public function setCostAttribute($cost): void + { + $this->attributes['cost'] = round($cost); + } } diff --git a/src/app/Http/Controllers/API/DomainsController.php b/src/app/Http/Controllers/API/DomainsController.php index 55b48bcb..e162d02c 100644 --- a/src/app/Http/Controllers/API/DomainsController.php +++ b/src/app/Http/Controllers/API/DomainsController.php @@ -1,225 +1,208 @@ user(); $list = []; foreach ($user->domains() as $domain) { if (!$domain->isPublic()) { $list[] = $domain->toArray(); } } return response()->json($list); } /** * Show the form for creating a new resource. * * @return \Illuminate\Http\JsonResponse */ public function create() { // } /** * Confirm ownership of the specified domain (via DNS check). * * @param int $id Domain identifier * * @return \Illuminate\Http\JsonResponse|void */ public function confirm($id) { $domain = Domain::findOrFail($id); // Only owner (or admin) has access to the domain - if (!self::hasAccess($domain)) { + if (!Auth::guard()->user()->canRead($domain)) { return $this->errorResponse(403); } if (!$domain->confirm()) { return response()->json(['status' => 'error']); } return response()->json([ 'status' => 'success', 'message' => __('app.domain-verify-success'), ]); } /** * Remove the specified resource from storage. * * @param int $id * * @return \Illuminate\Http\JsonResponse */ public function destroy($id) { // } /** * Show the form for editing the specified resource. * * @param int $id * * @return \Illuminate\Http\Response */ public function edit($id) { // } /** * Store a newly created resource in storage. * * @param \Illuminate\Http\Request $request * * @return \Illuminate\Http\Response */ public function store(Request $request) { // } /** * Get the information about the specified domain. * * @param int $id Domain identifier * * @return \Illuminate\Http\JsonResponse|void */ public function show($id) { $domain = Domain::findOrFail($id); // Only owner (or admin) has access to the domain - if (!self::hasAccess($domain)) { + if (!Auth::guard()->user()->canRead($domain)) { return $this->errorResponse(403); } $response = $domain->toArray(); // Add hash information to the response $response['hash_text'] = $domain->hash(Domain::HASH_TEXT); $response['hash_cname'] = $domain->hash(Domain::HASH_CNAME); $response['hash_code'] = $domain->hash(Domain::HASH_CODE); // Add DNS/MX configuration for the domain $response['dns'] = self::getDNSConfig($domain); $response['config'] = self::getMXConfig($domain->namespace); $response['confirmed'] = $domain->isConfirmed(); return response()->json($response); } /** * Update the specified resource in storage. * * @param \Illuminate\Http\Request $request * @param int $id * * @return \Illuminate\Http\Response */ public function update(Request $request, $id) { // } /** * Provide DNS MX information to configure specified domain for */ protected static function getMXConfig(string $namespace): array { $entries = []; // copy MX entries from an existing domain if ($master = \config('dns.copyfrom')) { // TODO: cache this lookup foreach ((array) dns_get_record($master, DNS_MX) as $entry) { $entries[] = sprintf( "@\t%s\t%s\tMX\t%d %s.", \config('dns.ttl', $entry['ttl']), $entry['class'], $entry['pri'], $entry['target'] ); } } elseif ($static = \config('dns.static')) { $entries[] = strtr($static, array('\n' => "\n", '%s' => $namespace)); } // display SPF settings if ($spf = \config('dns.spf')) { $entries[] = ';'; foreach (['TXT', 'SPF'] as $type) { $entries[] = sprintf( "@\t%s\tIN\t%s\t\"%s\"", \config('dns.ttl'), $type, $spf ); } } return $entries; } /** * Provide sample DNS config for domain confirmation */ protected static function getDNSConfig(Domain $domain): array { $serial = date('Ymd01'); $hash_txt = $domain->hash(Domain::HASH_TEXT); $hash_cname = $domain->hash(Domain::HASH_CNAME); $hash = $domain->hash(Domain::HASH_CODE); return [ "@ IN SOA ns1.dnsservice.com. hostmaster.{$domain->namespace}. (", " {$serial} 10800 3600 604800 86400 )", ";", "@ IN A ", "www IN A ", ";", "{$hash_cname}.{$domain->namespace}. IN CNAME {$hash}.{$domain->namespace}.", "@ 3600 TXT \"{$hash_txt}\"", ]; } - - /** - * Check if the current user has access to the domain - * - * @param \App\Domain $domain The domain - * - * @return bool True if current user has access, False otherwise - */ - protected static function hasAccess(Domain $domain): bool - { - $user = Auth::guard()->user(); - $entitlement = $domain->entitlement()->first(); - - // TODO: Admins - - return $entitlement && $entitlement->owner_id == $user->id; - } } diff --git a/src/app/Http/Controllers/API/UsersController.php b/src/app/Http/Controllers/API/UsersController.php index 5fe3293d..87fe3237 100644 --- a/src/app/Http/Controllers/API/UsersController.php +++ b/src/app/Http/Controllers/API/UsersController.php @@ -1,545 +1,554 @@ middleware('auth:api', ['except' => ['login']]); } /** * Helper method for other controllers with user auto-logon * functionality * * @param \App\User $user User model object */ public static function logonResponse(User $user) { $token = auth()->login($user); return response()->json([ 'status' => 'success', 'access_token' => $token, 'token_type' => 'bearer', 'expires_in' => Auth::guard()->factory()->getTTL() * 60, ]); } /** - * Display a listing of the resources. + * Delete a user. * - * The user themself, and other user entitlements. + * @param int $id User identifier * - * @return \Illuminate\Http\JsonResponse + * @return \Illuminate\Http\JsonResponse The response */ - public function index() + public function destroy($id) { - $user = Auth::user(); + $user = User::find($id); - if (!$user) { - return response()->json(['error' => 'unauthorized'], 401); + if (empty($user)) { + return $this->errorResponse(404); } - $result = [$user]; + // User can't remove himself until he's the controller + if (!$this->guard()->user()->canDelete($user)) { + return $this->errorResponse(403); + } - $user->entitlements()->each( - function ($entitlement) { - $result[] = User::find($entitlement->user_id); - } - ); + $user->delete(); + + return response()->json([ + 'status' => 'success', + 'message' => __('app.user-delete-success'), + ]); + } + + /** + * Listing of users. + * + * The user-entitlements billed to the current user wallet(s) + * + * @return \Illuminate\Http\JsonResponse + */ + public function index() + { + $user = $this->guard()->user(); + + $result = $user->users()->orderBy('email')->get(); return response()->json($result); } /** * Get the authenticated User * * @return \Illuminate\Http\JsonResponse */ public function info() { $user = $this->guard()->user(); $response = $this->userResponse($user); return response()->json($response); } /** * Get a JWT token via given credentials. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse */ public function login(Request $request) { $v = Validator::make( $request->all(), [ 'email' => 'required|min:2', 'password' => 'required|min:4', ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } $credentials = $request->only('email', 'password'); if ($token = $this->guard()->attempt($credentials)) { return $this->respondWithToken($token); } return response()->json(['status' => 'error', 'message' => __('auth.failed')], 401); } /** * Log the user out (Invalidate the token) * * @return \Illuminate\Http\JsonResponse */ public function logout() { $this->guard()->logout(); return response()->json([ 'status' => 'success', 'message' => __('auth.logoutsuccess') ]); } /** * Refresh a token. * * @return \Illuminate\Http\JsonResponse */ public function refresh() { return $this->respondWithToken($this->guard()->refresh()); } /** * Get the token array structure. * * @param string $token Respond with this token. * * @return \Illuminate\Http\JsonResponse */ protected function respondWithToken($token) { return response()->json( [ 'access_token' => $token, 'token_type' => 'bearer', 'expires_in' => $this->guard()->factory()->getTTL() * 60 ] ); } /** * Display information on the user account specified by $id. * * @param int $id The account to show information for. * * @return \Illuminate\Http\JsonResponse|void */ public function show($id) { - if (!$this->hasAccess($id)) { - return $this->errorResponse(403); - } - $user = User::find($id); if (empty($user)) { return $this->errorResponse(404); } + if (!$this->guard()->user()->canRead($user)) { + return $this->errorResponse(403); + } + $response = $this->userResponse($user); return response()->json($response); } /** * User status (extended) information * * @param \App\User $user User object * * @return array Status information */ public static function statusInfo(User $user): array { $status = 'new'; $process = []; $steps = [ 'user-new' => true, 'user-ldap-ready' => 'isLdapReady', 'user-imap-ready' => 'isImapReady', ]; if ($user->isDeleted()) { $status = 'deleted'; } elseif ($user->isSuspended()) { $status = 'suspended'; } elseif ($user->isActive()) { $status = 'active'; } list ($local, $domain) = explode('@', $user->email); $domain = Domain::where('namespace', $domain)->first(); // If that is not a public domain, add domain specific steps if ($domain && !$domain->isPublic()) { $steps['domain-new'] = true; $steps['domain-ldap-ready'] = 'isLdapReady'; $steps['domain-verified'] = 'isVerified'; $steps['domain-confirmed'] = 'isConfirmed'; } // Create a process check list foreach ($steps as $step_name => $func) { $object = strpos($step_name, 'user-') === 0 ? $user : $domain; $step = [ 'label' => $step_name, 'title' => __("app.process-{$step_name}"), 'state' => is_bool($func) ? $func : $object->{$func}(), ]; if ($step_name == 'domain-confirmed' && !$step['state']) { $step['link'] = "/domain/{$domain->id}"; } $process[] = $step; } return [ 'process' => $process, 'status' => $status, ]; } /** * Create a new user record. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function store(Request $request) { - if ($this->guard()->user()->controller()->id !== $this->guard()->user()->id) { + $current_user = $this->guard()->user(); + + if ($current_user->wallet()->owner->id != $current_user->id) { return $this->errorResponse(403); } if ($error_response = $this->validateUserRequest($request, null, $settings)) { return $error_response; } $user_name = !empty($settings['first_name']) ? $settings['first_name'] : ''; if (!empty($settings['last_name'])) { $user_name .= ' ' . $settings['last_name']; } DB::beginTransaction(); // Create user record $user = User::create([ 'name' => $user_name, 'email' => $request->email, 'password' => $request->password, ]); if (!empty($settings)) { $user->setSettings($settings); } // TODO: Assign package // Add aliases if (!empty($request->aliases)) { $user->setAliases($request->aliases); } DB::commit(); return response()->json([ 'status' => 'success', 'message' => __('app.user-create-success'), ]); } /** * Update user data. * * @param \Illuminate\Http\Request $request The API request. * @params string $id User identifier * * @return \Illuminate\Http\JsonResponse The response */ public function update(Request $request, $id) { - if (!$this->hasAccess($id)) { - return $this->errorResponse(403); - } - $user = User::find($id); if (empty($user)) { return $this->errorResponse(404); } + // TODO: Decide what attributes a user can change on his own profile + if (!$this->guard()->user()->canUpdate($user)) { + return $this->errorResponse(403); + } + if ($error_response = $this->validateUserRequest($request, $user, $settings)) { return $error_response; } DB::beginTransaction(); if (!empty($settings)) { $user->setSettings($settings); } // Update user password if (!empty($request->password)) { $user->password = $request->password; $user->save(); } // Update aliases if (isset($request->aliases)) { $user->setAliases($request->aliases); } DB::commit(); return response()->json([ 'status' => 'success', 'message' => __('app.user-update-success'), ]); } /** * Get the guard to be used during authentication. * * @return \Illuminate\Contracts\Auth\Guard */ public function guard() { return Auth::guard(); } - /** - * Check if the current user has access to the specified user - * - * @param int $user_id User identifier - * - * @return bool True if current user has access, False otherwise - */ - protected function hasAccess($user_id): bool - { - $current_user = $this->guard()->user(); - - // TODO: Admins, other users - // FIXME: This probably should be some kind of middleware/guard - - return $current_user->id == $user_id; - } - /** * Create a response data array for specified user. * * @param \App\User $user User object * * @return array Response data */ protected function userResponse(User $user): array { $response = $user->toArray(); // Settings // TODO: It might be reasonable to limit the list of settings here to these // that are safe and are used in the UI $response['settings'] = []; foreach ($user->settings as $item) { $response['settings'][$item->key] = $item->value; } // Aliases $response['aliases'] = []; foreach ($user->aliases as $item) { $response['aliases'][] = $item->alias; } // Status info $response['statusInfo'] = self::statusInfo($user); + // Information about wallets and accounts for access checks + $response['wallets'] = $user->wallets->toArray(); + $response['accounts'] = $user->accounts->toArray(); + $response['wallet'] = $user->wallet()->toArray(); + return $response; } /** * Validate user input * * @param \Illuminate\Http\Request $request The API request. * @param \App\User|null $user User identifier * @param array $settings User settings (from the request) * * @return \Illuminate\Http\JsonResponse The response on error */ protected function validateUserRequest(Request $request, $user, &$settings = []) { $rules = [ 'external_email' => 'nullable|email', 'phone' => 'string|nullable|max:64|regex:/^[0-9+() -]+$/', 'first_name' => 'string|nullable|max:512', 'last_name' => 'string|nullable|max:512', 'billing_address' => 'string|nullable|max:1024', 'country' => 'string|nullable|alpha|size:2', 'currency' => 'string|nullable|alpha|size:3', 'aliases' => 'array|nullable', ]; if (empty($user) || !empty($request->password) || !empty($request->password_confirmation)) { $rules['password'] = 'required|min:4|max:2048|confirmed'; } $errors = []; // Validate input $v = Validator::make($request->all(), $rules); if ($v->fails()) { $errors = $v->errors()->toArray(); } - $controller = $user ? $user->controller() : $this->guard()->user(); + $controller = $user ? $user->wallet()->owner : $this->guard()->user(); // For new user validate email address if (empty($user)) { $email = $request->email; if (empty($email)) { $errors['email'] = \trans('validation.required', ['attribute' => 'email']); } elseif ($error = self::validateEmail($email, $controller, false)) { $errors['email'] = $error; } } // Validate aliases input if (isset($request->aliases)) { $aliases = []; $existing_aliases = $user ? $user->aliases()->get()->pluck('alias')->toArray() : []; foreach ($request->aliases as $idx => $alias) { if (is_string($alias) && !empty($alias)) { // Alias cannot be the same as the email address (new user) if (!empty($email) && Str::lower($alias) == Str::lower($email)) { continue; } // validate new aliases if ( !in_array($alias, $existing_aliases) && ($error = self::validateEmail($alias, $controller, true)) ) { if (!isset($errors['aliases'])) { $errors['aliases'] = []; } $errors['aliases'][$idx] = $error; continue; } $aliases[] = $alias; } } $request->aliases = $aliases; } if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } // Update user settings $settings = $request->only(array_keys($rules)); unset($settings['password'], $settings['aliases'], $settings['email']); } /** * Email address (login or alias) validation * * @param string $email Email address * @param \App\User $user The account owner * @param bool $is_alias The email is an alias * * @return string Error message on validation error */ protected static function validateEmail(string $email, User $user, bool $is_alias = false): ?string { $attribute = $is_alias ? 'alias' : 'email'; if (strpos($email, '@') === false) { return \trans('validation.entryinvalid', ['attribute' => $attribute]); } list($login, $domain) = explode('@', $email); // Check if domain exists $domain = Domain::where('namespace', Str::lower($domain))->first(); if (empty($domain)) { return \trans('validation.domaininvalid'); } // Validate login part alone $v = Validator::make( [$attribute => $login], [$attribute => ['required', new UserEmailLocal(!$domain->isPublic())]] ); if ($v->fails()) { return $v->errors()->toArray()[$attribute][0]; } // Check if it is one of domains available to the user // TODO: We should have a helper that returns "flat" array with domain names // I guess we could use pluck() somehow $domains = array_map( function ($domain) { return $domain->namespace; }, $user->domains() ); if (!in_array($domain->namespace, $domains)) { return \trans('validation.entryexists', ['attribute' => 'domain']); } // Check if user with specified address already exists if (User::findByEmail($email)) { return \trans('validation.entryexists', ['attribute' => $attribute]); } return null; } } diff --git a/src/app/Jobs/DomainDelete.php b/src/app/Jobs/DomainDelete.php index ed78894e..25f872bd 100644 --- a/src/app/Jobs/DomainDelete.php +++ b/src/app/Jobs/DomainDelete.php @@ -1,48 +1,53 @@ domain = $domain; + $this->domain = Domain::withTrashed()->find($domain_id); } /** * Execute the job. * * @return void */ public function handle() { - LDAP::deleteDomain($this->domain); + if (!$this->domain->isDeleted()) { + LDAP::deleteDomain($this->domain); + + $this->domain->status |= Domain::STATUS_DELETED; + $this->domain->save(); + } } } diff --git a/src/app/Jobs/UserDelete.php b/src/app/Jobs/UserDelete.php index 22c40208..7cd9ac9f 100644 --- a/src/app/Jobs/UserDelete.php +++ b/src/app/Jobs/UserDelete.php @@ -1,48 +1,53 @@ user = User::withTrashed()->find($user_id); } /** * Execute the job. * * @return void */ public function handle() { - LDAP::deleteUser($this->user); + if (!$this->user->isDeleted()) { + LDAP::deleteUser($this->user); + + $this->user->status |= User::STATUS_DELETED; + $this->user->save(); + } } } diff --git a/src/app/Observers/DomainObserver.php b/src/app/Observers/DomainObserver.php index b1ce5660..bde36395 100644 --- a/src/app/Observers/DomainObserver.php +++ b/src/app/Observers/DomainObserver.php @@ -1,94 +1,107 @@ {$domain->getKeyName()} = $allegedly_unique; break; } } $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, then check if it exists in DNS \App\Jobs\DomainCreate::dispatch($domain); } + /** + * Handle the domain "deleting" event. + * + * @param \App\Domain $domain The domain. + * + * @return void + */ public function deleting(Domain $domain) { - // + // Entitlements do not have referential integrity on the entitled object, so this is our + // way of doing an onDelete('cascade') without the foreign key. + \App\Entitlement::where('entitleable_id', $domain->id) + ->where('entitleable_type', Domain::class) + ->delete(); } /** - * Handle the domain "updated" event. + * Handle the domain "deleted" event. * * @param \App\Domain $domain The domain. * * @return void */ - public function updated(Domain $domain) + public function deleted(Domain $domain) { - // + \App\Jobs\DomainDelete::dispatch($domain->id); } /** - * Handle the domain "deleted" event. + * Handle the domain "updated" event. * * @param \App\Domain $domain The domain. * * @return void */ - public function deleted(Domain $domain) + public function updated(Domain $domain) { - \App\Jobs\DomainDelete::dispatch($domain); + // } + /** * Handle the domain "restored" event. * * @param \App\Domain $domain The domain. * * @return void */ public function restored(Domain $domain) { // } /** * Handle the domain "force deleted" event. * * @param \App\Domain $domain The domain. * * @return void */ public function forceDeleted(Domain $domain) { // } } diff --git a/src/app/Observers/UserObserver.php b/src/app/Observers/UserObserver.php index 44a3f455..21b78988 100644 --- a/src/app/Observers/UserObserver.php +++ b/src/app/Observers/UserObserver.php @@ -1,119 +1,164 @@ {$user->getKeyName()} = $allegedly_unique; break; } } $user->status |= User::STATUS_NEW; // can't dispatch job here because it'll fail serialization } /** * 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) { // FIXME: Actual proper settings $user->setSettings( [ 'country' => 'CH', 'currency' => 'CHF', 'first_name' => '', 'last_name' => '', 'billing_address' => '', 'organization' => '' ] ); $user->wallets()->create(); // Create user record in LDAP, then check if the account is created in IMAP $chain = [ new \App\Jobs\UserVerify($user), ]; \App\Jobs\UserCreate::withChain($chain)->dispatch($user); } public function deleted(User $user) { // } /** * Handle the "deleting" event. * * @param User $user The user that is being deleted. * * @return void */ public function deleting(User $user) { + // TODO: Especially in tests we're doing delete() on a already deleted user. + // Should we escape here - for performance reasons? + // TODO: I think all of this should use database transactions + // Entitlements do not have referential integrity on the entitled object, so this is our // way of doing an onDelete('cascade') without the foreign key. - $entitlements = \App\Entitlement::where('entitleable_id', $user->id) - ->where('entitleable_type', \App\User::class)->get(); + Entitlement::where('entitleable_id', $user->id) + ->where('entitleable_type', User::class) + ->delete(); + + // Remove owned users/domains + $wallets = $user->wallets()->pluck('id')->all(); + $assignments = Entitlement::whereIn('wallet_id', $wallets)->get(); + $users = []; + $domains = []; + $entitlements = []; - foreach ($entitlements as $entitlement) { - $entitlement->delete(); + foreach ($assignments as $entitlement) { + if ($entitlement->entitleable_type == Domain::class) { + $domains[] = $entitlement->entitleable_id; + } elseif ($entitlement->entitleable_type == User::class && $entitlement->entitleable_id != $user->id) { + $users[] = $entitlement->entitleable_id; + } else { + $entitlements[] = $entitlement->id; + } + } + + $users = array_unique($users); + $domains = array_unique($domains); + + // Note: Domains/users need to be deleted one by one to make sure + // events are fired and observers can do the proper cleanup. + // Entitlements have no delete event handlers as for now. + if (!empty($users)) { + foreach (User::whereIn('id', $users)->get() as $_user) { + $_user->delete(); + } } + if (!empty($domains)) { + foreach (Domain::whereIn('id', $domains)->get() as $_domain) { + $_domain->delete(); + } + } + + if (!empty($entitlements)) { + Entitlement::whereIn('id', $entitlements)->delete(); + } + + // FIXME: What do we do with user wallets? + \App\Jobs\UserDelete::dispatch($user->id); } /** * Handle the "retrieving" event. * * @param User $user The user that is being retrieved. * * @todo This is useful for audit. * * @return void */ public function retrieving(User $user) { // TODO \App\Jobs\UserRead::dispatch($user); } /** * Handle the "updating" event. * * @param User $user The user that is being updated. * * @return void */ public function updating(User $user) { \App\Jobs\UserUpdate::dispatch($user); } } diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php index 3b8916eb..c19a5e48 100644 --- a/src/app/Providers/AppServiceProvider.php +++ b/src/app/Providers/AppServiceProvider.php @@ -1,48 +1,47 @@ sql, implode(', ', $query->bindings))); }); } } } diff --git a/src/app/User.php b/src/app/User.php index d6d5fcd0..3896ebc8 100644 --- a/src/app/User.php +++ b/src/app/User.php @@ -1,449 +1,531 @@ 'datetime', ]; /** * Any wallets on which this user is a controller. * + * This does not include wallets owned by the user. + * * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany */ public function accounts() { return $this->belongsToMany( 'App\Wallet', // The foreign object definition 'user_accounts', // The table name 'user_id', // The local foreign key 'wallet_id' // The remote foreign key ); } /** * Email aliases of this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function aliases() { return $this->hasMany('App\UserAlias', 'user_id'); } /** * Assign a package to a user. The user should not have any existing entitlements. * * @param \App\Package $package The package to assign. * @param \App\User|null $user Assign the package to another user. * * @return \App\User */ public function assignPackage($package, $user = null) { if (!$user) { $user = $this; } $wallet_id = $this->wallets()->get()[0]->id; foreach ($package->skus as $sku) { for ($i = $sku->pivot->qty; $i > 0; $i--) { \App\Entitlement::create( [ 'owner_id' => $this->id, 'wallet_id' => $wallet_id, 'sku_id' => $sku->id, 'cost' => $sku->pivot->cost(), 'entitleable_id' => $user->id, 'entitleable_type' => User::class ] ); } } return $user; } - /** - * Returns user controlling the current user (or self when it's the account owner) - * - * @return \App\User A user object - */ - public function controller(): User - { - // FIXME: This is most likely not the best way to do this - $entitlement = \App\Entitlement::where([ - 'entitleable_id' => $this->id, - 'entitleable_type' => User::class - ])->first(); - - if ($entitlement && $entitlement->owner_id != $this->id) { - return $entitlement->owner; - } - - return $this; - } - public function assignPlan($plan, $domain = null) { $this->setSetting('plan_id', $plan->id); foreach ($plan->packages as $package) { if ($package->isDomain()) { $domain->assignPackage($package, $this); } else { $this->assignPackage($package); } } } + /** + * Check if current user can delete another object. + * + * @param \App\User|\App\Domain $object A user|domain object + * + * @return bool True if he can, False otherwise + */ + public function canDelete($object): bool + { + if (!method_exists($object, 'wallet')) { + return false; + } + + $wallet = $object->wallet(); + + // TODO: For now controller can delete/update the account owner, + // this may change in future, controllers are not 0-regression feature + + return $this->wallets->contains($wallet) || $this->accounts->contains($wallet); + } + + /** + * Check if current user can read data of another object. + * + * @param \App\User|\App\Domain $object A user|domain object + * + * @return bool True if he can, False otherwise + */ + public function canRead($object): bool + { + if (!method_exists($object, 'wallet')) { + return false; + } + + if ($object instanceof User && $this->id == $object->id) { + return true; + } + + $wallet = $object->wallet(); + + return $this->wallets->contains($wallet) || $this->accounts->contains($wallet); + } + + /** + * Check if current user can update data of another object. + * + * @param \App\User|\App\Domain $object A user|domain object + * + * @return bool True if he can, False otherwise + */ + public function canUpdate($object): bool + { + if (!method_exists($object, 'wallet')) { + return false; + } + + if ($object instanceof User && $this->id == $object->id) { + return true; + } + + return $this->canDelete($object); + } + /** * List the domains to which this user is entitled. * * @return Domain[] */ public function domains() { $dbdomains = Domain::whereRaw( sprintf( '(type & %s) AND (status & %s)', Domain::TYPE_PUBLIC, Domain::STATUS_ACTIVE ) )->get(); $domains = []; foreach ($dbdomains as $dbdomain) { $domains[] = $dbdomain; } $entitlements = Entitlement::where('owner_id', $this->id)->get(); foreach ($entitlements as $entitlement) { if ($entitlement->entitleable instanceof Domain) { $domain = $entitlement->entitleable; \Log::info("Found domain for {$this->email}: {$domain->namespace} (owned)"); $domains[] = $domain; } } foreach ($this->accounts as $wallet) { foreach ($wallet->entitlements as $entitlement) { if ($entitlement->entitleable instanceof Domain) { $domain = $entitlement->entitleable; \Log::info("Found domain {$this->email}: {$domain->namespace} (charged)"); $domains[] = $domain; } } } return $domains; } public function entitlement() { return $this->morphOne('App\Entitlement', 'entitleable'); } /** * Entitlements for this user. * * Note that these are entitlements that apply to the user account, and not entitlements that * this user owns. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function entitlements() { return $this->hasMany('App\Entitlement', 'entitleable_id', 'id'); } public function addEntitlement($entitlement) { if (!$this->entitlements->contains($entitlement)) { return $this->entitlements()->save($entitlement); } } /** * Helper to find user by email address, whether it is * main email address, alias or external email * * @param string $email Email address * * @return \App\User User model object if found */ public static function findByEmail(string $email): ?User { if (strpos($email, '@') === false) { return null; } $email = \strtolower($email); $user = self::where('email', $email)->first(); if ($user) { return $user; } $alias = UserAlias::where('alias', $email)->first(); if ($alias) { return $alias->user; } // TODO: External email return null; } public function getJWTIdentifier() { return $this->getKey(); } public function getJWTCustomClaims() { return []; } /** * Returns whether this domain is active. * * @return bool */ public function isActive(): bool { return ($this->status & self::STATUS_ACTIVE) > 0; } /** * Returns whether this domain is deleted. * * @return bool */ public function isDeleted(): bool { return ($this->status & self::STATUS_DELETED) > 0; } /** * Returns whether this (external) domain has been verified * to exist in DNS. * * @return bool */ public function isImapReady(): bool { return ($this->status & self::STATUS_IMAP_READY) > 0; } /** * Returns whether this user is registered in LDAP. * * @return bool */ public function isLdapReady(): bool { return ($this->status & self::STATUS_LDAP_READY) > 0; } /** * Returns whether this user is new. * * @return bool */ public function isNew(): bool { return ($this->status & self::STATUS_NEW) > 0; } /** * Returns whether this domain is suspended. * * @return bool */ public function isSuspended(): bool { return ($this->status & self::STATUS_SUSPENDED) > 0; } /** * Any (additional) properties of this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function settings() { return $this->hasMany('App\UserSetting', 'user_id'); } + /** + * Return users controlled by the current user. + * + * Users assigned to wallets the current user controls or owns. + * + * @return \Illuminate\Database\Eloquent\Builder Query builder + */ + public function users() + { + $wallets = array_merge( + $this->wallets()->pluck('id')->all(), + $this->accounts()->pluck('wallet_id')->all() + ); + + return $this->select(['users.*', 'entitlements.wallet_id']) + ->distinct() + ->leftJoin('entitlements', 'entitlements.entitleable_id', '=', 'users.id') + ->whereIn('entitlements.wallet_id', $wallets) + ->where('entitlements.entitleable_type', 'App\User'); + } + /** * Verification codes for this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function verificationcodes() { return $this->hasMany('App\VerificationCode', 'user_id', 'id'); } + /** + * Returns the wallet by which the user is controlled + * + * @return \App\Wallet A wallet object + */ + public function wallet(): Wallet + { + $entitlement = $this->entitlement()->first(); + + // TODO: No entitlement should not happen, but in tests we have + // such cases, so we fallback to the user's wallet in this case + return $entitlement ? $entitlement->wallet : $this->wallets()->first(); + } + /** * Wallets this user owns. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function wallets() { return $this->hasMany('App\Wallet'); } /** * User password mutator * * @param string $password The password in plain text. * * @return void */ public function setPasswordAttribute($password) { if (!empty($password)) { $this->attributes['password'] = bcrypt($password, [ "rounds" => 12 ]); $this->attributes['password_ldap'] = '{SSHA512}' . base64_encode( pack('H*', hash('sha512', $password)) ); } } /** * User LDAP password mutator * * @param string $password The password in plain text. * * @return void */ public function setPasswordLdapAttribute($password) { if (!empty($password)) { $this->attributes['password'] = bcrypt($password, [ "rounds" => 12 ]); $this->attributes['password_ldap'] = '{SSHA512}' . base64_encode( pack('H*', hash('sha512', $password)) ); } } /** * User status mutator * * @throws \Exception */ public function setStatusAttribute($status) { $new_status = 0; $allowed_values = [ self::STATUS_NEW, self::STATUS_ACTIVE, self::STATUS_SUSPENDED, self::STATUS_DELETED, self::STATUS_LDAP_READY, self::STATUS_IMAP_READY, ]; foreach ($allowed_values as $value) { if ($status & $value) { $new_status |= $value; $status ^= $value; } } if ($status > 0) { throw new \Exception("Invalid user status: {$status}"); } $this->attributes['status'] = $new_status; } } diff --git a/src/composer.json b/src/composer.json index 6bc7c2a3..1ed9b370 100644 --- a/src/composer.json +++ b/src/composer.json @@ -1,83 +1,84 @@ { "name": "laravel/laravel", "type": "project", "description": "The Laravel Framework.", "keywords": [ "framework", "laravel" ], "license": "MIT", "repositories": [ { "type": "vcs", "url": "https://git.kolab.org/diffusion/PNL/php-net_ldap3.git" } ], "require": { "php": "^7.1.3", "doctrine/dbal": "^2.9", "fideloper/proxy": "^4.0", "geoip2/geoip2": "^2.9", "iatstuti/laravel-nullable-fields": "*", "kolab/net_ldap3": "dev-master", "laravel/framework": "6.*", "laravel/tinker": "^1.0", + "morrislaptop/laravel-queue-clear": "^1.2", "silviolleite/laravelpwa": "^1.0", "spatie/laravel-translatable": "^4.2", "swooletw/laravel-swoole": "^2.6", "torann/currency": "^1.0", "torann/geoip": "^1.0", "tymon/jwt-auth": "^1.0" }, "require-dev": { "beyondcode/laravel-dump-server": "^1.0", "beyondcode/laravel-er-diagram-generator": "^1.3", "filp/whoops": "^2.0", "fzaninotto/faker": "^1.4", "laravel/dusk": "5.9.1", "mockery/mockery": "^1.0", "nunomaduro/collision": "^3.0", "nunomaduro/larastan": "^0.4", "phpstan/phpstan": "0.11.19", "phpunit/phpunit": "^7.5" }, "config": { "optimize-autoloader": true, "preferred-install": "dist", "sort-packages": true }, "extra": { "laravel": { "dont-discover": [] } }, "autoload": { "psr-4": { "App\\": "app/" }, "classmap": [ "database/seeds", "database/factories", "include" ] }, "autoload-dev": { "psr-4": { "Tests\\": "tests/" } }, "minimum-stability": "dev", "prefer-stable": true, "scripts": { "post-autoload-dump": [ "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", "@php artisan package:discover --ansi" ], "post-root-package-install": [ "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"" ], "post-create-project-cmd": [ "@php artisan key:generate --ansi" ] } } diff --git a/src/database/seeds/UserSeeder.php b/src/database/seeds/UserSeeder.php index b884a956..96b34b87 100644 --- a/src/database/seeds/UserSeeder.php +++ b/src/database/seeds/UserSeeder.php @@ -1,92 +1,116 @@ 'kolab.org', 'status' => Domain::STATUS_NEW + Domain::STATUS_ACTIVE + Domain::STATUS_CONFIRMED + Domain::STATUS_VERIFIED, 'type' => Domain::TYPE_EXTERNAL ] ); $john = User::create( [ 'name' => 'John Doe', 'email' => 'john@kolab.org', 'password' => 'simple123', 'email_verified_at' => now() ] ); $john->setSettings( [ 'first_name' => 'John', 'last_name' => 'Doe', 'currency' => 'USD', 'country' => 'US', 'billing_address' => "601 13th Street NW\nSuite 900 South\nWashington, DC 20005", 'external_email' => 'john.doe.external@gmail.com', 'phone' => '+1 509-248-1111', ] ); $john->setAliases(['john.doe@kolab.org']); $wallet = $john->wallets->first(); $package_domain = \App\Package::where('title', 'domain-hosting')->first(); $package_kolab = \App\Package::where('title', 'kolab')->first(); $domain->assignPackage($package_domain, $john); $john->assignPackage($package_kolab); $jack = User::create( [ 'name' => 'Jack Daniels', 'email' => 'jack@kolab.org', 'password' => 'simple123', 'email_verified_at' => now() ] ); $jack->setSettings( [ 'first_name' => 'Jack', 'last_name' => 'Daniels', 'currency' => 'USD', 'country' => 'US' ] ); $jack->setAliases(['jack.daniels@kolab.org']); $john->assignPackage($package_kolab, $jack); foreach ($john->entitlements as $entitlement) { $entitlement->created_at = Carbon::now()->subMonths(1); $entitlement->updated_at = Carbon::now()->subMonths(1); $entitlement->save(); } + $ned = User::create( + [ + 'name' => 'Edward Flanders', + 'email' => 'ned@kolab.org', + 'password' => 'simple123', + 'email_verified_at' => now() + ] + ); + + $ned->setSettings( + [ + 'first_name' => 'Edward', + 'last_name' => 'Flanders', + 'currency' => 'USD', + 'country' => 'US' + ] + ); + + $john->assignPackage($package_kolab, $ned); + + // Ned is a controller on Jack's wallet + $john->wallets()->first()->addController($ned); + factory(User::class, 10)->create(); } } diff --git a/src/phpunit.xml b/src/phpunit.xml index 2953ba09..59c9361b 100644 --- a/src/phpunit.xml +++ b/src/phpunit.xml @@ -1,41 +1,41 @@ - - tests/Browser - - tests/Unit tests/Feature + + + tests/Browser + ./app diff --git a/src/resources/js/app.js b/src/resources/js/app.js index 2014bdba..e1c2fc2a 100644 --- a/src/resources/js/app.js +++ b/src/resources/js/app.js @@ -1,182 +1,199 @@ /** * First we will load all of this project's JavaScript dependencies which * includes Vue and other libraries. It is a great starting point when * building robust, powerful web applications using Vue and Laravel. */ require('./bootstrap') window.Vue = require('vue') import AppComponent from '../vue/components/App' import MenuComponent from '../vue/components/Menu' import router from '../vue/js/routes.js' import store from '../vue/js/store' import FontAwesomeIcon from './fontawesome.js' import VueToastr from '@deveodk/vue-toastr' Vue.component('svg-icon', FontAwesomeIcon) Vue.use(VueToastr, { defaultPosition: 'toast-bottom-right', defaultTimeout: 5000 }) // Add a response interceptor for general/validation error handler // This have to be before Vue and Router setup. Otherwise we would // not be able to handle axios responses initiated from inside // components created/mounted handlers (e.g. signup code verification link) window.axios.interceptors.response.use( response => { // Do nothing return response }, error => { var error_msg if (error.response && error.response.status == 422) { error_msg = "Form validation error" $.each(error.response.data.errors || {}, (idx, msg) => { $('form').each((i, form) => { const input_name = ($(form).data('validation-prefix') || '') + idx const input = $('#' + input_name) if (input.length) { // Create an error message\ // API responses can use a string, array or object let msg_text = '' if ($.type(msg) !== 'string') { $.each(msg, (index, str) => { msg_text += str + ' ' }) } else { msg_text = msg } let feedback = $('
').text(msg_text) if (input.is('.listinput')) { // List input widget let list = input.next('.listinput-widget') list.children(':not(:first-child)').each((index, element) => { if (msg[index]) { $(element).find('input').addClass('is-invalid') } }) list.addClass('is-invalid').next('.invalid-feedback').remove() list.after(feedback) } else { // Standard form element input.addClass('is-invalid') input.parent().find('.invalid-feedback').remove() input.parent().append(feedback) } return false } }); }) $('form .is-invalid:not(.listinput-widget)').first().focus() } else if (error.response && error.response.data) { error_msg = error.response.data.message } else { error_msg = error.request ? error.request.statusText : error.message } app.$toastr('error', error_msg || "Server Error", 'Error') // Pass the error as-is return Promise.reject(error) } ) const app = new Vue({ el: '#app', components: { 'app-component': AppComponent, 'menu-component': MenuComponent }, store, router, data() { return { isLoading: true } }, methods: { // Clear (bootstrap) form validation state clearFormValidation(form) { $(form).find('.is-invalid').removeClass('is-invalid') $(form).find('.invalid-feedback').remove() }, + isController(wallet_id) { + if (wallet_id && store.state.authInfo) { + let i + for (i = 0; i < store.state.authInfo.wallets.length; i++) { + if (wallet_id == store.state.authInfo.wallets[i].id) { + return true + } + } + for (i = 0; i < store.state.authInfo.accounts.length; i++) { + if (wallet_id == store.state.authInfo.accounts[i].id) { + return true + } + } + } + + return false + }, // Set user state to "logged in" loginUser(token, dashboard) { store.commit('logoutUser') // destroy old state data store.commit('loginUser') localStorage.setItem('token', token) axios.defaults.headers.common.Authorization = 'Bearer ' + token if (dashboard !== false) { router.push(store.state.afterLogin || { name: 'dashboard' }) } store.state.afterLogin = null }, // Set user state to "not logged in" logoutUser() { store.commit('logoutUser') localStorage.setItem('token', '') delete axios.defaults.headers.common.Authorization router.push({ name: 'login' }) }, // Display "loading" overlay (to be used by route components) startLoading() { this.isLoading = true // Lock the UI with the 'loading...' element $('#app').append($('
Loading
')) }, // Hide "loading" overlay stopLoading() { $('#app > .app-loader').fadeOut() this.isLoading = false }, errorPage(code, msg) { // Until https://github.com/vuejs/vue-router/issues/977 is implemented // we can't really use router to display error page as it has two side // effects: it changes the URL and adds the error page to browser history. // For now we'll be replacing current view with error page "manually". const map = { 400: "Bad request", 401: "Unauthorized", 403: "Access denied", 404: "Not found", 405: "Method not allowed", 500: "Internal server error" } if (!msg) msg = map[code] || "Unknown Error" const error_page = `
${code}
${msg}
` $('#app').children(':not(nav)').remove() $('#app').append(error_page) }, errorHandler(error) { this.stopLoading() if (error.response.status === 401) { this.logoutUser() } else { this.errorPage(error.response.status, error.response.statusText) } } } }) diff --git a/src/resources/lang/en/app.php b/src/resources/lang/en/app.php index 441cad6b..60de9d3f 100644 --- a/src/resources/lang/en/app.php +++ b/src/resources/lang/en/app.php @@ -1,26 +1,27 @@ 'Choose :plan', 'process-user-new' => 'User registered', 'process-user-ldap-ready' => 'User created', 'process-user-imap-ready' => 'User mailbox created', 'process-domain-new' => 'Custom domain registered', 'process-domain-ldap-ready' => 'Custom domain created', 'process-domain-verified' => 'Custom domain verified', 'process-domain-confirmed' => 'Custom domain ownership verified', - 'domain-verify-success' => 'Domain verified successfully', - 'user-update-success' => 'User data updated successfully', - 'user-create-success' => 'User created successfully', + 'domain-verify-success' => 'Domain verified successfully.', + 'user-update-success' => 'User data updated successfully.', + 'user-create-success' => 'User created successfully.', + 'user-delete-success' => 'User deleted successfully.', ]; diff --git a/src/resources/lang/en/auth.php b/src/resources/lang/en/auth.php index ac837d06..7c672437 100644 --- a/src/resources/lang/en/auth.php +++ b/src/resources/lang/en/auth.php @@ -1,19 +1,19 @@ 'Invalid username or password.', - 'throttle' => 'Too many login attempts. Please try again in :seconds seconds', - 'logoutsuccess' => 'Successfully logged out', + 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', + 'logoutsuccess' => 'Successfully logged out.', ]; diff --git a/src/resources/vue/components/User/Info.vue b/src/resources/vue/components/User/Info.vue index e0d6236b..871ed662 100644 --- a/src/resources/vue/components/User/Info.vue +++ b/src/resources/vue/components/User/Info.vue @@ -1,189 +1,189 @@ diff --git a/src/resources/vue/components/User/List.vue b/src/resources/vue/components/User/List.vue index 56068383..19d0d6bb 100644 --- a/src/resources/vue/components/User/List.vue +++ b/src/resources/vue/components/User/List.vue @@ -1,45 +1,114 @@ diff --git a/src/resources/vue/components/User/Profile.vue b/src/resources/vue/components/User/Profile.vue index fb900da7..359e878a 100644 --- a/src/resources/vue/components/User/Profile.vue +++ b/src/resources/vue/components/User/Profile.vue @@ -1,101 +1,105 @@ diff --git a/src/resources/vue/components/User/ProfileDelete.vue b/src/resources/vue/components/User/ProfileDelete.vue new file mode 100644 index 00000000..4ed7cb25 --- /dev/null +++ b/src/resources/vue/components/User/ProfileDelete.vue @@ -0,0 +1,47 @@ + + + diff --git a/src/resources/vue/js/routes.js b/src/resources/vue/js/routes.js index 04308360..7a4ebd54 100644 --- a/src/resources/vue/js/routes.js +++ b/src/resources/vue/js/routes.js @@ -1,108 +1,115 @@ import Vue from 'vue' import VueRouter from 'vue-router' Vue.use(VueRouter) import DashboardComponent from '../components/Dashboard' import DomainInfoComponent from '../components/Domain/Info' import DomainListComponent from '../components/Domain/List' import Error404Component from '../components/404' import LoginComponent from '../components/Login' import LogoutComponent from '../components/Logout' import PasswordResetComponent from '../components/PasswordReset' import SignupComponent from '../components/Signup' import UserInfoComponent from '../components/User/Info' import UserListComponent from '../components/User/List' import UserProfileComponent from '../components/User/Profile' +import UserProfileDeleteComponent from '../components/User/ProfileDelete' import store from './store' const routes = [ { path: '/', redirect: { name: 'dashboard' } }, { path: '/dashboard', name: 'dashboard', component: DashboardComponent, meta: { requiresAuth: true } }, { path: '/domain/:domain', name: 'domain', component: DomainInfoComponent, meta: { requiresAuth: true } }, { path: '/domains', name: 'domains', component: DomainListComponent, meta: { requiresAuth: true } }, { path: '/login', name: 'login', component: LoginComponent }, { path: '/logout', name: 'logout', component: LogoutComponent }, { path: '/password-reset/:code?', name: 'password-reset', component: PasswordResetComponent }, { path: '/profile', name: 'profile', component: UserProfileComponent, meta: { requiresAuth: true } }, + { + path: '/profile/delete', + name: 'profile-delete', + component: UserProfileDeleteComponent, + meta: { requiresAuth: true } + }, { path: '/signup/:param?', name: 'signup', component: SignupComponent }, { path: '/user/:user', name: 'user', component: UserInfoComponent, meta: { requiresAuth: true } }, { path: '/users', name: 'users', component: UserListComponent, meta: { requiresAuth: true } }, { name: '404', path: '*', component: Error404Component } ] const router = new VueRouter({ mode: 'history', routes }) router.beforeEach((to, from, next) => { // check if the route requires authentication and user is not logged in if (to.matched.some(route => route.meta.requiresAuth) && !store.state.isLoggedIn) { // remember the original request, to use after login store.state.afterLogin = to; // redirect to login page next({ name: 'login' }) return } next() }) export default router diff --git a/src/tests/Browser.php b/src/tests/Browser.php index 06841171..36a73e04 100644 --- a/src/tests/Browser.php +++ b/src/tests/Browser.php @@ -1,118 +1,140 @@ elements($selector); $count = count($elements); if ($visible) { foreach ($elements as $element) { if (!$element->isDisplayed()) { $count--; } } } Assert::assertEquals($expected_count, $count); return $this; } + /** + * Assert specified error page is displayed. + */ + public function assertErrorPage(int $error_code) + { + $this->with(new Error($error_code), function ($browser) { + // empty, assertions will be made by the Error component itself + }); + + return $this; + } + /** * Assert that the given element has specified class assigned. */ public function assertHasClass($selector, $class_name) { $element = $this->resolver->findOrFail($selector); $classes = explode(' ', (string) $element->getAttribute('class')); Assert::assertContains($class_name, $classes); return $this; } + /** + * Remove all toast messages + */ + public function clearToasts() + { + $this->script("jQuery('.toast-container > *').remove()"); + + return $this; + } + /** * Check if in Phone mode */ public static function isPhone() { return getenv('TESTS_MODE') == 'phone'; } /** * Check if in Tablet mode */ public static function isTablet() { return getenv('TESTS_MODE') == 'tablet'; } /** * Check if in Desktop mode */ public static function isDesktop() { return !self::isPhone() && !self::isTablet(); } /** * Returns content of a downloaded file */ public function readDownloadedFile($filename) { $filename = __DIR__ . "/Browser/downloads/$filename"; // Give the browser a chance to finish download if (!file_exists($filename)) { sleep(2); } Assert::assertFileExists($filename); return file_get_contents($filename); } /** * Removes downloaded file */ public function removeDownloadedFile($filename) { @unlink(__DIR__ . "/Browser/downloads/$filename"); return $this; } /** * Execute code within body context. * Useful to execute code that selects elements outside of a component context */ public function withinBody($callback) { if ($this->resolver->prefix != 'body') { $orig_prefix = $this->resolver->prefix; $this->resolver->prefix = 'body'; } call_user_func($callback, $this); if (isset($orig_prefix)) { $this->resolver->prefix = $orig_prefix; } return $this; } } diff --git a/src/tests/Browser/Components/Error.php b/src/tests/Browser/Components/Dialog.php similarity index 53% copy from src/tests/Browser/Components/Error.php copy to src/tests/Browser/Components/Dialog.php index 1365fe61..1dfdf30e 100644 --- a/src/tests/Browser/Components/Error.php +++ b/src/tests/Browser/Components/Dialog.php @@ -1,60 +1,54 @@ 'Not Found' - ]; + protected $selector; - public function __construct($code) + + public function __construct($selector) { - $this->code = $code; - $this->message = $this->messages_map[$code]; + $this->selector = trim($selector); } /** * Get the root selector for the component. * * @return string */ public function selector() { - return '#error-page'; + return $this->selector; } /** * Assert that the browser page contains the component. * * @param \Laravel\Dusk\Browser $browser * * @return void */ public function assert($browser) { - $browser->waitFor($this->selector()) - ->assertSeeIn('@code', $this->code) - ->assertSeeIn('@message', $this->message); + $browser->waitFor($this->selector() . '.modal.show'); } /** * Get the element shortcuts for the component. * * @return array */ public function elements() { - $selector = $this->selector(); - return [ - '@code' => "$selector .code", - '@message' => "$selector .message", + '@title' => '.modal-header .modal-title', + '@body' => '.modal-body', + '@button-action' => '.modal-footer button.modal-action', + '@button-cancel' => '.modal-footer button.modal-cancel', ]; } } diff --git a/src/tests/Browser/Components/Error.php b/src/tests/Browser/Components/Error.php index 1365fe61..02121c22 100644 --- a/src/tests/Browser/Components/Error.php +++ b/src/tests/Browser/Components/Error.php @@ -1,60 +1,65 @@ 'Not Found' + 400 => "Bad request", + 401 => "Unauthorized", + 403 => "Access denied", + 404 => "Not Found", + 405 => "Method not allowed", + 500 => "Internal server error", ]; public function __construct($code) { $this->code = $code; $this->message = $this->messages_map[$code]; } /** * Get the root selector for the component. * * @return string */ public function selector() { return '#error-page'; } /** * Assert that the browser page contains the component. * * @param \Laravel\Dusk\Browser $browser * * @return void */ public function assert($browser) { $browser->waitFor($this->selector()) ->assertSeeIn('@code', $this->code) ->assertSeeIn('@message', $this->message); } /** * Get the element shortcuts for the component. * * @return array */ public function elements() { $selector = $this->selector(); return [ '@code' => "$selector .code", '@message' => "$selector .message", ]; } } diff --git a/src/tests/Browser/DomainTest.php b/src/tests/Browser/DomainTest.php index 924584be..ac2c9a19 100644 --- a/src/tests/Browser/DomainTest.php +++ b/src/tests/Browser/DomainTest.php @@ -1,135 +1,132 @@ browse(function ($browser) { $browser->visit('/domain/123')->on(new Home()); }); } /** * Test domain info page (non-existing domain id) */ public function testDomainInfo404(): void { $this->browse(function ($browser) { // FIXME: I couldn't make loginAs() method working // Note: Here we're also testing that unauthenticated request // is passed to logon form and then "redirected" to the requested page $browser->visit('/domain/123') ->on(new Home()) ->submitLogon('john@kolab.org', 'simple123') - // TODO: the check below could look simpler, but we can't - // just remove the callback argument. We'll create - // Browser wrapper in future, then we could create expectError() method - ->with(new Error('404'), function ($browser) { - }); + ->assertErrorPage(404); }); } /** * Test domain info page (existing domain) * * @depends testDomainInfo404 */ public function testDomainInfo(): void { $this->browse(function ($browser) { // Unconfirmed domain $domain = Domain::where('namespace', 'kolab.org')->first(); $domain->status ^= Domain::STATUS_CONFIRMED; $domain->save(); $browser->visit('/domain/' . $domain->id) ->on(new DomainInfo()) ->whenAvailable('@verify', function ($browser) use ($domain) { // Make sure the domain is confirmed now // TODO: Test verification process failure $domain->status |= Domain::STATUS_CONFIRMED; $domain->save(); $browser->assertSeeIn('pre', $domain->namespace) ->assertSeeIn('pre', $domain->hash()) ->click('button'); }) ->whenAvailable('@config', function ($browser) use ($domain) { $browser->assertSeeIn('pre', $domain->namespace); }) ->assertMissing('@verify') ->with(new Toast(Toast::TYPE_SUCCESS), function ($browser) { $browser->assertToastTitle('') ->assertToastMessage('Domain verified successfully') ->closeToast(); }); // Check that confirmed domain page contains only the config box $browser->visit('/domain/' . $domain->id) ->on(new DomainInfo()) ->assertMissing('@verify') ->assertPresent('@config'); }); } /** * Test domains list page (unauthenticated) */ public function testDomainListUnauth(): void { // Test that the page requires authentication $this->browse(function ($browser) { $browser->visit('/logout') ->visit('/domains') ->on(new Home()); }); } /** * Test domains list page * * @depends testDomainListUnauth */ public function testDomainList(): void { $this->browse(function ($browser) { // Login the user $browser->visit('/login') ->on(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) // On dashboard click the "Domains" link ->on(new Dashboard()) ->assertSeeIn('@links a.link-domains', 'Domains') ->click('@links a.link-domains') // On Domains List page click the domain entry ->on(new DomainList()) ->assertSeeIn('@table tbody tr:first-child td:first-child', 'kolab.org') ->click('@table tbody tr:first-child td:first-child a') // On Domain Info page verify that's the clicked domain ->on(new DomainInfo()) ->whenAvailable('@config', function ($browser) { $browser->assertSeeIn('pre', 'kolab.org'); }); }); + + // TODO: Test domains list acting as Ned (John's "delegatee") } } diff --git a/src/tests/Browser/Pages/Home.php b/src/tests/Browser/Pages/Home.php index c19acca3..cea31d50 100644 --- a/src/tests/Browser/Pages/Home.php +++ b/src/tests/Browser/Pages/Home.php @@ -1,65 +1,65 @@ assertPathIs($this->url()) + $browser->waitForLocation($this->url()) ->assertVisible('form.form-signin'); } /** * Get the element shortcuts for the page. * * @return array */ public function elements() { return [ '@app' => '#app', ]; } /** * Submit logon form. * * @param \Laravel\Dusk\Browser $browser The browser object * @param string $username User name * @param string $password User password * @param bool $wait_for_dashboard * * @return void */ public function submitLogon($browser, $username, $password, $wait_for_dashboard = false) { $browser ->type('#inputEmail', $username) ->type('#inputPassword', $password) ->press('form button'); if ($wait_for_dashboard) { $browser->waitForLocation('/dashboard'); } } } diff --git a/src/tests/Browser/PasswordResetTest.php b/src/tests/Browser/PasswordResetTest.php index 03a4a09b..ef1092c4 100644 --- a/src/tests/Browser/PasswordResetTest.php +++ b/src/tests/Browser/PasswordResetTest.php @@ -1,275 +1,274 @@ deleteTestUser('passwordresettestdusk@' . \config('app.domain')); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('passwordresettestdusk@' . \config('app.domain')); parent::tearDown(); } /** * Test the link from logon to password-reset page */ public function testPasswordResetLinkOnLogon(): void { $this->browse(function (Browser $browser) { $browser->visit(new Home()); $browser->assertSeeLink('Forgot password?'); $browser->clickLink('Forgot password?'); $browser->on(new PasswordReset()); $browser->assertVisible('@step1'); }); } /** * Test 1st step of password-reset */ public function testPasswordResetStep1(): void { $user = $this->getTestUser('passwordresettestdusk@' . \config('app.domain')); $user->setSetting('external_email', 'external@domain.tld'); $this->browse(function (Browser $browser) { $browser->visit(new PasswordReset()); $browser->assertVisible('@step1'); // Here we expect email input and submit button $browser->with('@step1', function ($step) { $step->assertVisible('#reset_email'); $step->assertFocused('#reset_email'); $step->assertVisible('[type=submit]'); }); // Submit empty form $browser->with('@step1', function ($step) { $step->click('[type=submit]'); $step->assertFocused('#reset_email'); }); // Submit invalid email // We expect email input to have is-invalid class added, with .invalid-feedback element $browser->with('@step1', function ($step) use ($browser) { $step->type('#reset_email', '@test'); $step->click('[type=submit]'); $step->waitFor('#reset_email.is-invalid'); $step->waitFor('#reset_email + .invalid-feedback'); $browser->waitFor('.toast-error'); $browser->click('.toast-error'); // remove the toast }); // Submit valid data $browser->with('@step1', function ($step) { $step->type('#reset_email', 'passwordresettestdusk@' . \config('app.domain')); $step->click('[type=submit]'); $step->assertMissing('#reset_email.is-invalid'); $step->assertMissing('#reset_email + .invalid-feedback'); }); $browser->waitUntilMissing('@step2 #reset_code[value=""]'); $browser->waitFor('@step2'); $browser->assertMissing('@step1'); }); } /** * Test 2nd Step of the password reset process * * @depends testPasswordResetStep1 */ public function testPasswordResetStep2(): void { $user = $this->getTestUser('passwordresettestdusk@' . \config('app.domain')); $user->setSetting('external_email', 'external@domain.tld'); $this->browse(function (Browser $browser) { $browser->assertVisible('@step2'); // Here we expect one text input, Back and Continue buttons $browser->with('@step2', function ($step) { $step->assertVisible('#reset_short_code'); $step->assertFocused('#reset_short_code'); $step->assertVisible('[type=button]'); $step->assertVisible('[type=submit]'); }); // Test Back button functionality $browser->click('@step2 [type=button]'); $browser->waitFor('@step1'); $browser->assertFocused('@step1 #reset_email'); $browser->assertMissing('@step2'); // Submit valid Step 1 data (again) $browser->with('@step1', function ($step) { $step->type('#reset_email', 'passwordresettestdusk@' . \config('app.domain')); $step->click('[type=submit]'); }); $browser->waitFor('@step2'); $browser->assertMissing('@step1'); // Submit invalid code // We expect code input to have is-invalid class added, with .invalid-feedback element $browser->with('@step2', function ($step) use ($browser) { $step->type('#reset_short_code', 'XXXXX'); $step->click('[type=submit]'); $browser->waitFor('.toast-error'); $step->assertVisible('#reset_short_code.is-invalid'); $step->assertVisible('#reset_short_code + .invalid-feedback'); $step->assertFocused('#reset_short_code'); $browser->click('.toast-error'); // remove the toast }); // Submit valid code // We expect error state on code input to be removed, and Step 3 form visible $browser->with('@step2', function ($step) { // Get the code and short_code from database // FIXME: Find a nice way to read javascript data without using hidden inputs $code = $step->value('#reset_code'); $this->assertNotEmpty($code); $code = VerificationCode::find($code); $step->type('#reset_short_code', $code->short_code); $step->click('[type=submit]'); $step->assertMissing('#reset_short_code.is-invalid'); $step->assertMissing('#reset_short_code + .invalid-feedback'); }); $browser->waitFor('@step3'); $browser->assertMissing('@step2'); }); } /** * Test 3rd Step of the password reset process * * @depends testPasswordResetStep2 */ public function testPasswordResetStep3(): void { $user = $this->getTestUser('passwordresettestdusk@' . \config('app.domain')); $user->setSetting('external_email', 'external@domain.tld'); $this->browse(function (Browser $browser) { $browser->assertVisible('@step3'); // Here we expect 2 text inputs, Back and Continue buttons $browser->with('@step3', function ($step) { $step->assertVisible('#reset_password'); $step->assertVisible('#reset_confirm'); $step->assertVisible('[type=button]'); $step->assertVisible('[type=submit]'); $step->assertFocused('#reset_password'); }); // Test Back button $browser->click('@step3 [type=button]'); $browser->waitFor('@step2'); $browser->assertFocused('@step2 #reset_short_code'); $browser->assertMissing('@step3'); $browser->assertMissing('@step1'); // TODO: Test form reset when going back // Because the verification code is removed in tearDown() // we'll start from the beginning (Step 1) $browser->click('@step2 [type=button]'); $browser->waitFor('@step1'); $browser->assertFocused('@step1 #reset_email'); $browser->assertMissing('@step3'); $browser->assertMissing('@step2'); // Submit valid data $browser->with('@step1', function ($step) { $step->type('#reset_email', 'passwordresettestdusk@' . \config('app.domain')); $step->click('[type=submit]'); }); $browser->waitFor('@step2'); $browser->waitUntilMissing('@step2 #reset_code[value=""]'); // Submit valid code again $browser->with('@step2', function ($step) { $code = $step->value('#reset_code'); $this->assertNotEmpty($code); $code = VerificationCode::find($code); $step->type('#reset_short_code', $code->short_code); $step->click('[type=submit]'); }); $browser->waitFor('@step3'); // Submit invalid data $browser->with('@step3', function ($step) use ($browser) { $step->assertFocused('#reset_password'); $step->type('#reset_password', '12345678'); $step->type('#reset_confirm', '123456789'); $step->click('[type=submit]'); $browser->waitFor('.toast-error'); $step->assertVisible('#reset_password.is-invalid'); $step->assertVisible('#reset_password + .invalid-feedback'); $step->assertFocused('#reset_password'); $browser->click('.toast-error'); // remove the toast }); // Submit valid data $browser->with('@step3', function ($step) { $step->type('#reset_confirm', '12345678'); $step->click('[type=submit]'); }); $browser->waitUntilMissing('@step3'); // At this point we should be auto-logged-in to dashboard - $dashboard = new Dashboard(); - $dashboard->assert($browser); + $browser->on(new Dashboard()); // FIXME: Is it enough to be sure user is logged in? }); } } diff --git a/src/tests/Browser/SignupTest.php b/src/tests/Browser/SignupTest.php index c2aea0de..0c1e5b1e 100644 --- a/src/tests/Browser/SignupTest.php +++ b/src/tests/Browser/SignupTest.php @@ -1,502 +1,502 @@ deleteTestUser('signuptestdusk@' . \config('app.domain')); $this->deleteTestUser('admin@user-domain-signup.com'); $this->deleteTestDomain('user-domain-signup.com'); } public function tearDown(): void { $this->deleteTestUser('signuptestdusk@' . \config('app.domain')); $this->deleteTestUser('admin@user-domain-signup.com'); $this->deleteTestDomain('user-domain-signup.com'); parent::tearDown(); } /** * Test signup code verification with a link */ public function testSignupCodeByLink(): void { // Test invalid code (invalid format) $this->browse(function (Browser $browser) { // Register Signup page element selectors we'll be using $browser->onWithoutAssert(new Signup()); // TODO: Test what happens if user is logged in $browser->visit('/signup/invalid-code'); // TODO: According to https://github.com/vuejs/vue-router/issues/977 // it is not yet easily possible to display error page component (route) // without changing the URL // TODO: Instead of css selector we should probably define page/component // and use it instead $browser->waitFor('#error-page'); }); // Test invalid code (valid format) $this->browse(function (Browser $browser) { $browser->visit('/signup/XXXXX-code'); // FIXME: User will not be able to continue anyway, so we should // either display 1st step or 404 error page $browser->waitFor('@step1') ->with(new Toast(Toast::TYPE_ERROR), function (Browser $browser) { $browser->assertToastTitle('Error') ->assertToastMessage('Form validation error') ->closeToast(); }); }); // Test valid code $this->browse(function (Browser $browser) { $code = SignupCode::create([ 'data' => [ 'email' => 'User@example.org', 'name' => 'User Name', 'plan' => 'individual', ] ]); $browser->visit('/signup/' . $code->short_code . '-' . $code->code); $browser->waitFor('@step3'); $browser->assertMissing('@step1'); $browser->assertMissing('@step2'); // FIXME: Find a nice way to read javascript data without using hidden inputs $this->assertSame($code->code, $browser->value('@step2 #signup_code')); // TODO: Test if the signup process can be completed }); } /** * Test signup "welcome" page */ public function testSignupStep0(): void { $this->browse(function (Browser $browser) { $browser->visit(new Signup()); $browser->assertVisible('@step0') ->assertMissing('@step1') ->assertMissing('@step2') ->assertMissing('@step3'); $browser->within(new Menu(), function ($browser) { $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'login']); $browser->assertActiveItem('signup'); }); $browser->waitFor('@step0 .plan-selector > .plan-box'); // Assert first plan box and press the button $browser->with('@step0 .plan-selector > .plan-individual', function ($step) { $step->assertVisible('button') ->assertSeeIn('button', 'Individual Account') ->assertVisible('.plan-description') ->click('button'); }); $browser->waitForLocation('/signup/individual') ->assertVisible('@step1') ->assertMissing('@step0') ->assertMissing('@step2') ->assertMissing('@step3') ->assertFocused('@step1 #signup_name'); // Click Back button $browser->click('@step1 [type=button]') ->waitForLocation('/signup') ->assertVisible('@step0') ->assertMissing('@step1') ->assertMissing('@step2') ->assertMissing('@step3'); // Choose the group account plan $browser->click('@step0 .plan-selector > .plan-group button') ->waitForLocation('/signup/group') ->assertVisible('@step1') ->assertMissing('@step0') ->assertMissing('@step2') ->assertMissing('@step3') ->assertFocused('@step1 #signup_name'); // TODO: Test if 'plan' variable is set properly in vue component }); } /** * Test 1st step of the signup process */ public function testSignupStep1(): void { $this->browse(function (Browser $browser) { $browser->visit('/signup/individual')->onWithoutAssert(new Signup()); $browser->assertVisible('@step1'); $browser->within(new Menu(), function ($browser) { $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'login']); $browser->assertActiveItem('signup'); }); // Here we expect two text inputs and Back and Continue buttons $browser->with('@step1', function ($step) { $step->assertVisible('#signup_name') ->assertFocused('#signup_name') ->assertVisible('#signup_email') ->assertVisible('[type=button]') ->assertVisible('[type=submit]'); }); // Submit empty form // Both Step 1 inputs are required, so after pressing Submit // we expect focus to be moved to the first input $browser->with('@step1', function ($step) { $step->click('[type=submit]'); $step->assertFocused('#signup_name'); }); // Submit invalid email // We expect email input to have is-invalid class added, with .invalid-feedback element $browser->with('@step1', function ($step) use ($browser) { $step->type('#signup_name', 'Test User') ->type('#signup_email', '@test') ->click('[type=submit]') ->waitFor('#signup_email.is-invalid') ->assertVisible('#signup_email + .invalid-feedback'); $browser->with(new Toast(Toast::TYPE_ERROR), function (Browser $browser) { $browser->assertToastTitle('Error') ->assertToastMessage('Form validation error') ->closeToast(); }); }); // Submit valid data // We expect error state on email input to be removed, and Step 2 form visible $browser->with('@step1', function ($step) { $step->type('#signup_name', 'Test User'); $step->type('#signup_email', 'BrowserSignupTestUser1@kolab.org'); $step->click('[type=submit]'); $step->assertMissing('#signup_email.is-invalid'); $step->assertMissing('#signup_email + .invalid-feedback'); }); $browser->waitUntilMissing('@step2 #signup_code[value=""]'); $browser->waitFor('@step2'); $browser->assertMissing('@step1'); }); } /** * Test 2nd Step of the signup process * * @depends testSignupStep1 */ public function testSignupStep2(): void { $this->browse(function (Browser $browser) { $browser->assertVisible('@step2') ->assertMissing('@step0') ->assertMissing('@step1') ->assertMissing('@step3'); // Here we expect one text input, Back and Continue buttons $browser->with('@step2', function ($step) { $step->assertVisible('#signup_short_code') ->assertFocused('#signup_short_code') ->assertVisible('[type=button]') ->assertVisible('[type=submit]'); }); // Test Back button functionality $browser->click('@step2 [type=button]') ->waitFor('@step1') ->assertFocused('@step1 #signup_name') ->assertMissing('@step2'); // Submit valid Step 1 data (again) $browser->with('@step1', function ($step) { $step->type('#signup_name', 'Test User'); $step->type('#signup_email', 'BrowserSignupTestUser1@kolab.org'); $step->click('[type=submit]'); }); $browser->waitFor('@step2'); $browser->assertMissing('@step1'); // Submit invalid code // We expect code input to have is-invalid class added, with .invalid-feedback element $browser->with('@step2', function ($step) use ($browser) { $step->type('#signup_short_code', 'XXXXX'); $step->click('[type=submit]'); $step->waitFor('#signup_short_code.is-invalid') ->assertVisible('#signup_short_code + .invalid-feedback') ->assertFocused('#signup_short_code'); $browser->with(new Toast(Toast::TYPE_ERROR), function (Browser $browser) { $browser->assertToastTitle('Error') ->assertToastMessage('Form validation error') ->closeToast(); }); }); // Submit valid code // We expect error state on code input to be removed, and Step 3 form visible $browser->with('@step2', function ($step) { // Get the code and short_code from database // FIXME: Find a nice way to read javascript data without using hidden inputs $code = $step->value('#signup_code'); $this->assertNotEmpty($code); $code = SignupCode::find($code); $step->type('#signup_short_code', $code->short_code); $step->click('[type=submit]'); $step->assertMissing('#signup_short_code.is-invalid'); $step->assertMissing('#signup_short_code + .invalid-feedback'); }); $browser->waitFor('@step3'); $browser->assertMissing('@step2'); }); } /** * Test 3rd Step of the signup process * * @depends testSignupStep2 */ public function testSignupStep3(): void { $this->browse(function (Browser $browser) { $browser->assertVisible('@step3'); // Here we expect 3 text inputs, Back and Continue buttons $browser->with('@step3', function ($step) { $step->assertVisible('#signup_login'); $step->assertVisible('#signup_password'); $step->assertVisible('#signup_confirm'); $step->assertVisible('select#signup_domain'); $step->assertVisible('[type=button]'); $step->assertVisible('[type=submit]'); $step->assertFocused('#signup_login'); $step->assertValue('select#signup_domain', \config('app.domain')); $step->assertValue('#signup_login', ''); $step->assertValue('#signup_password', ''); $step->assertValue('#signup_confirm', ''); // TODO: Test domain selector }); // Test Back button $browser->click('@step3 [type=button]'); $browser->waitFor('@step2'); $browser->assertFocused('@step2 #signup_short_code'); $browser->assertMissing('@step3'); // TODO: Test form reset when going back // Submit valid code again $browser->with('@step2', function ($step) { $code = $step->value('#signup_code'); $this->assertNotEmpty($code); $code = SignupCode::find($code); $step->type('#signup_short_code', $code->short_code); $step->click('[type=submit]'); }); $browser->waitFor('@step3'); // Submit invalid data $browser->with('@step3', function ($step) use ($browser) { $step->assertFocused('#signup_login') ->type('#signup_login', '*') ->type('#signup_password', '12345678') ->type('#signup_confirm', '123456789') ->click('[type=submit]') ->waitFor('#signup_login.is-invalid') ->assertVisible('#signup_domain + .invalid-feedback') ->assertVisible('#signup_password.is-invalid') ->assertVisible('#signup_password + .invalid-feedback') ->assertFocused('#signup_login'); $browser->with(new Toast(Toast::TYPE_ERROR), function (Browser $browser) { $browser->assertToastTitle('Error') ->assertToastMessage('Form validation error') ->closeToast(); }); }); // Submit invalid data (valid login, invalid password) $browser->with('@step3', function ($step) use ($browser) { $step->type('#signup_login', 'SignupTestDusk') ->click('[type=submit]') ->waitFor('#signup_password.is-invalid') ->assertVisible('#signup_password + .invalid-feedback') ->assertMissing('#signup_login.is-invalid') ->assertMissing('#signup_domain + .invalid-feedback') ->assertFocused('#signup_password'); $browser->with(new Toast(Toast::TYPE_ERROR), function (Browser $browser) { $browser->assertToastTitle('Error') ->assertToastMessage('Form validation error') ->closeToast(); }); }); // Submit valid data $browser->with('@step3', function ($step) { $step->type('#signup_confirm', '12345678'); $step->click('[type=submit]'); }); // At this point we should be auto-logged-in to dashboard $browser->waitUntilMissing('@step3') ->waitUntilMissing('.app-loader') ->on(new Dashboard()) ->assertVue('data.email', 'signuptestdusk@' . \config('app.domain'), '@dashboard-component'); // Logout the user $browser->click('a.link-logout'); }); } /** * Test signup for a group account */ public function testSignupGroup(): void { $this->browse(function (Browser $browser) { $browser->visit(new Signup()); // Choose the group account plan $browser->waitFor('@step0 .plan-group button') ->click('@step0 .plan-group button'); // Submit valid data // We expect error state on email input to be removed, and Step 2 form visible $browser->whenAvailable('@step1', function ($step) { $step->type('#signup_name', 'Test User') ->type('#signup_email', 'BrowserSignupTestUser1@kolab.org') ->click('[type=submit]'); }); // Submit valid code $browser->whenAvailable('@step2', function ($step) { // Get the code and short_code from database // FIXME: Find a nice way to read javascript data without using hidden inputs $code = $step->value('#signup_code'); $code = SignupCode::find($code); $step->type('#signup_short_code', $code->short_code) ->click('[type=submit]'); }); // Here we expect 4 text inputs, Back and Continue buttons $browser->whenAvailable('@step3', function ($step) { $step->assertVisible('#signup_login') ->assertVisible('#signup_password') ->assertVisible('#signup_confirm') ->assertVisible('input#signup_domain') ->assertVisible('[type=button]') ->assertVisible('[type=submit]') ->assertFocused('#signup_login') ->assertValue('input#signup_domain', '') ->assertValue('#signup_login', '') ->assertValue('#signup_password', '') ->assertValue('#signup_confirm', ''); }); // Submit invalid login and password data $browser->with('@step3', function ($step) use ($browser) { $step->assertFocused('#signup_login') ->type('#signup_login', '*') ->type('#signup_domain', 'test.com') ->type('#signup_password', '12345678') ->type('#signup_confirm', '123456789') ->click('[type=submit]') ->waitFor('#signup_login.is-invalid') ->assertVisible('#signup_domain + .invalid-feedback') ->assertVisible('#signup_password.is-invalid') ->assertVisible('#signup_password + .invalid-feedback') ->assertFocused('#signup_login'); $browser->with(new Toast(Toast::TYPE_ERROR), function (Browser $browser) { $browser->assertToastTitle('Error') ->assertToastMessage('Form validation error') ->closeToast(); }); }); // Submit invalid domain $browser->with('@step3', function ($step) use ($browser) { $step->type('#signup_login', 'admin') ->type('#signup_domain', 'aaa') ->type('#signup_password', '12345678') ->type('#signup_confirm', '12345678') ->click('[type=submit]') ->waitUntilMissing('#signup_login.is-invalid') ->assertVisible('#signup_domain.is-invalid + .invalid-feedback') ->assertMissing('#signup_password.is-invalid') ->assertMissing('#signup_password + .invalid-feedback') ->assertFocused('#signup_domain'); $browser->with(new Toast(Toast::TYPE_ERROR), function (Browser $browser) { $browser->assertToastTitle('Error') ->assertToastMessage('Form validation error') ->closeToast(); }); }); // Submit invalid domain - $browser->with('@step3', function ($step) use ($browser) { + $browser->with('@step3', function ($step) { $step->type('#signup_domain', 'user-domain-signup.com') ->click('[type=submit]'); }); // At this point we should be auto-logged-in to dashboard $browser->waitUntilMissing('@step3') ->waitUntilMissing('.app-loader') ->on(new Dashboard()) ->assertVue('data.email', 'admin@user-domain-signup.com', '@dashboard-component'); $browser->click('a.link-logout'); }); } } diff --git a/src/tests/Browser/UserProfileTest.php b/src/tests/Browser/UserProfileTest.php index 71a93ddc..a7dba0d5 100644 --- a/src/tests/Browser/UserProfileTest.php +++ b/src/tests/Browser/UserProfileTest.php @@ -1,127 +1,199 @@ 'John', 'last_name' => 'Doe', 'currency' => 'USD', 'country' => 'US', 'billing_address' => "601 13th Street NW\nSuite 900 South\nWashington, DC 20005", 'external_email' => 'john.doe.external@gmail.com', 'phone' => '+1 509-248-1111', ]; /** * {@inheritDoc} */ public function setUp(): void { parent::setUp(); User::where('email', 'john@kolab.org')->first()->setSettings($this->profile); + $this->deleteTestUser('profile-delete@kolabnow.com'); } /** * {@inheritDoc} */ public function tearDown(): void { User::where('email', 'john@kolab.org')->first()->setSettings($this->profile); + $this->deleteTestUser('profile-delete@kolabnow.com'); parent::tearDown(); } /** * Test profile page (unauthenticated) */ public function testProfileUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $browser->visit('/profile')->on(new Home()); }); } /** * Test profile page */ public function testProfile(): void { $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) ->on(new Dashboard()) ->assertSeeIn('@links .link-profile', 'Your profile') ->click('@links .link-profile') ->on(new UserProfile()) + ->assertSeeIn('#user-profile .button-delete', 'Delete account') ->whenAvailable('@form', function (Browser $browser) { // Assert form content $browser->assertFocused('div.row:nth-child(1) input') ->assertSeeIn('div.row:nth-child(1) label', 'First name') ->assertValue('div.row:nth-child(1) input[type=text]', $this->profile['first_name']) ->assertSeeIn('div.row:nth-child(2) label', 'Last name') ->assertValue('div.row:nth-child(2) input[type=text]', $this->profile['last_name']) ->assertSeeIn('div.row:nth-child(3) label', 'Phone') ->assertValue('div.row:nth-child(3) input[type=text]', $this->profile['phone']) ->assertSeeIn('div.row:nth-child(4) label', 'External email') ->assertValue('div.row:nth-child(4) input[type=text]', $this->profile['external_email']) ->assertSeeIn('div.row:nth-child(5) label', 'Address') ->assertValue('div.row:nth-child(5) textarea', $this->profile['billing_address']) ->assertSeeIn('div.row:nth-child(6) label', 'Country') ->assertValue('div.row:nth-child(6) select', $this->profile['country']) ->assertSeeIn('div.row:nth-child(7) label', 'Password') ->assertValue('div.row:nth-child(7) input[type=password]', '') ->assertSeeIn('div.row:nth-child(8) label', 'Confirm password') ->assertValue('div.row:nth-child(8) input[type=password]', '') ->assertSeeIn('button[type=submit]', 'Submit'); // Clear all fields and submit // FIXME: Should any of these fields be required? $browser->type('#first_name', '') ->type('#last_name', '') ->type('#phone', '') ->type('#external_email', '') ->type('#billing_address', '') ->select('#country', '') ->click('button[type=submit]'); }) ->with(new Toast(Toast::TYPE_SUCCESS), function (Browser $browser) { $browser->assertToastTitle('') ->assertToastMessage('User data updated successfully') ->closeToast(); }); - // Test error handling $browser->with('@form', function (Browser $browser) { $browser->type('#phone', 'aaaaaa') ->type('#external_email', 'bbbbb') ->click('button[type=submit]') ->waitFor('#phone + .invalid-feedback') ->assertSeeIn('#phone + .invalid-feedback', 'The phone format is invalid.') ->assertSeeIn( '#external_email + .invalid-feedback', 'The external email must be a valid email address.' ) ->assertFocused('#phone'); }) ->with(new Toast(Toast::TYPE_ERROR), function (Browser $browser) { $browser->assertToastTitle('Error') ->assertToastMessage('Form validation error') ->closeToast(); }); }); } + + /** + * Test profile of non-controller user + */ + public function testProfileNonController(): void + { + // Test acting as non-controller + $this->browse(function (Browser $browser) { + $browser->visit('/logout') + ->visit(new Home()) + ->submitLogon('jack@kolab.org', 'simple123', true) + ->on(new Dashboard()) + ->assertSeeIn('@links .link-profile', 'Your profile') + ->click('@links .link-profile') + ->on(new UserProfile()) + ->assertMissing('#user-profile .button-delete') + ->whenAvailable('@form', function (Browser $browser) { + // TODO: decide on what fields the non-controller user should be able + // to see/change + }); + + // Test that /profile/delete page is not accessible + $browser->visit('/profile/delete') + ->assertErrorPage(403); + }); + } + + /** + * Test profile delete page + */ + public function testProfileDelete(): void + { + $user = $this->getTestUser('profile-delete@kolabnow.com', ['password' => 'simple123']); + + $this->browse(function (Browser $browser) use ($user) { + $browser->visit('/logout') + ->on(new Home()) + ->submitLogon('profile-delete@kolabnow.com', 'simple123', true) + ->on(new Dashboard()) + ->clearToasts() + ->assertSeeIn('@links .link-profile', 'Your profile') + ->click('@links .link-profile') + ->on(new UserProfile()) + ->click('#user-profile .button-delete') + ->waitForLocation('/profile/delete') + ->assertSeeIn('#user-delete .card-title', 'Delete this account?') + ->assertSeeIn('#user-delete .button-cancel', 'Cancel') + ->assertSeeIn('#user-delete .card-text', 'This operation is irreversible') + ->assertFocused('#user-delete .button-cancel') + ->click('#user-delete .button-cancel') + ->waitForLocation('/profile') + ->on(new UserProfile()); + + // Test deleting the user + $browser->click('#user-profile .button-delete') + ->waitForLocation('/profile/delete') + ->click('#user-delete .button-delete') + ->waitForLocation('/login') + ->with(new Toast(Toast::TYPE_SUCCESS), function (Browser $browser) { + $browser->assertToastTitle('') + ->assertToastMessage('User deleted successfully.') + ->closeToast(); + }); + + $this->assertTrue($user->fresh()->trashed()); + }); + } + + // TODO: Test that Ned (John's "delegatee") can delete himself + // TODO: Test that Ned (John's "delegatee") can/can't delete John ? } diff --git a/src/tests/Browser/UsersTest.php b/src/tests/Browser/UsersTest.php index 2a925791..7c9ce4c2 100644 --- a/src/tests/Browser/UsersTest.php +++ b/src/tests/Browser/UsersTest.php @@ -1,305 +1,399 @@ 'John', 'last_name' => 'Doe', ]; /** * {@inheritDoc} */ public function setUp(): void { parent::setUp(); - // TODO: Use TestCase::deleteTestUser() - User::withTrashed()->where('email', 'john.rambo@kolab.org')->forceDelete(); + $this->deleteTestUser('julia.roberts@kolab.org'); $john = User::where('email', 'john@kolab.org')->first(); $john->setSettings($this->profile); UserAlias::where('user_id', $john->id) ->where('alias', 'john.test@kolab.org')->delete(); } /** * {@inheritDoc} */ public function tearDown(): void { - // TODO: Use TestCase::deleteTestUser() - User::withTrashed()->where('email', 'john.rambo@kolab.org')->forceDelete(); + $this->deleteTestUser('julia.roberts@kolab.org'); $john = User::where('email', 'john@kolab.org')->first(); $john->setSettings($this->profile); UserAlias::where('user_id', $john->id) ->where('alias', 'john.test@kolab.org')->delete(); parent::tearDown(); } /** * Test user info page (unauthenticated) */ public function testInfoUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $user = User::where('email', 'john@kolab.org')->first(); $browser->visit('/user/' . $user->id)->on(new Home()); }); } /** * Test users list page (unauthenticated) */ public function testListUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $browser->visit('/users')->on(new Home()); }); - - // TODO: Test that jack@kolab.org can't access this page } /** * Test users list page */ public function testList(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) ->on(new Dashboard()) ->assertSeeIn('@links .link-users', 'User accounts') ->click('@links .link-users') ->on(new UserList()) - ->whenAvailable('@table', function ($browser) { - $this->assertCount(1, $browser->elements('tbody tr')); - $browser->assertSeeIn('tbody tr td a', 'john@kolab.org'); + ->whenAvailable('@table', function (Browser $browser) { + $browser->assertElementsCount('tbody tr', 3) + ->assertSeeIn('tbody tr:nth-child(1) a', 'jack@kolab.org') + ->assertSeeIn('tbody tr:nth-child(2) a', 'john@kolab.org') + ->assertSeeIn('tbody tr:nth-child(3) a', 'ned@kolab.org') + ->assertVisible('tbody tr:nth-child(1) button.button-delete') + ->assertVisible('tbody tr:nth-child(2) button.button-delete') + ->assertVisible('tbody tr:nth-child(3) button.button-delete'); }); }); } /** * Test user account editing page (not profile page) * * @depends testList */ public function testInfo(): void { $this->browse(function (Browser $browser) { $browser->on(new UserList()) - ->click('@table tr:first-child a') + ->click('@table tr:nth-child(2) a') ->on(new UserInfo()) ->assertSeeIn('#user-info .card-title', 'User account') ->with('@form', function (Browser $browser) { // Assert form content $browser->assertFocused('div.row:nth-child(1) input') ->assertSeeIn('div.row:nth-child(1) label', 'First name') ->assertValue('div.row:nth-child(1) input[type=text]', $this->profile['first_name']) ->assertSeeIn('div.row:nth-child(2) label', 'Last name') ->assertValue('div.row:nth-child(2) input[type=text]', $this->profile['last_name']) ->assertSeeIn('div.row:nth-child(3) label', 'Email') ->assertValue('div.row:nth-child(3) input[type=text]', 'john@kolab.org') -//TODO ->assertDisabled('div.row:nth-child(3) input') + ->assertDisabled('div.row:nth-child(3) input[type=text]') ->assertSeeIn('div.row:nth-child(4) label', 'Email aliases') ->assertVisible('div.row:nth-child(4) .listinput-widget') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertListInputValue(['john.doe@kolab.org']) ->assertValue('@input', ''); }) ->assertSeeIn('div.row:nth-child(5) label', 'Password') ->assertValue('div.row:nth-child(5) input[type=password]', '') ->assertSeeIn('div.row:nth-child(6) label', 'Confirm password') ->assertValue('div.row:nth-child(6) input[type=password]', '') ->assertSeeIn('button[type=submit]', 'Submit'); // Clear some fields and submit $browser->type('#first_name', '') ->type('#last_name', '') ->click('button[type=submit]'); }) ->with(new Toast(Toast::TYPE_SUCCESS), function (Browser $browser) { $browser->assertToastTitle('') ->assertToastMessage('User data updated successfully') ->closeToast(); }); // Test error handling (password) $browser->with('@form', function (Browser $browser) { $browser->type('#password', 'aaaaaa') ->type('#password_confirmation', '') ->click('button[type=submit]') ->waitFor('#password + .invalid-feedback') ->assertSeeIn('#password + .invalid-feedback', 'The password confirmation does not match.') ->assertFocused('#password'); }) ->with(new Toast(Toast::TYPE_ERROR), function (Browser $browser) { $browser->assertToastTitle('Error') ->assertToastMessage('Form validation error') ->closeToast(); }); // TODO: Test password change // Test form error handling (aliases) $browser->with('@form', function (Browser $browser) { // TODO: For some reason, clearing the input value // with ->type('#password', '') does not work, maybe some dusk/vue intricacy // For now we just use the default password $browser->type('#password', 'simple123') ->type('#password_confirmation', 'simple123') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->addListEntry('invalid address'); }) ->click('button[type=submit]'); }) ->with(new Toast(Toast::TYPE_ERROR), function (Browser $browser) { $browser->assertToastTitle('Error') ->assertToastMessage('Form validation error') ->closeToast(); }) ->with('@form', function (Browser $browser) { $browser->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertFormError(2, 'The specified alias is invalid.', false); }); }); // Test adding aliases $browser->with('@form', function (Browser $browser) { $browser->with(new ListInput('#aliases'), function (Browser $browser) { $browser->removeListEntry(2) ->addListEntry('john.test@kolab.org'); }) ->click('button[type=submit]'); }) ->with(new Toast(Toast::TYPE_SUCCESS), function (Browser $browser) { $browser->assertToastTitle('') ->assertToastMessage('User data updated successfully') ->closeToast(); }); $john = User::where('email', 'john@kolab.org')->first(); $alias = UserAlias::where('user_id', $john->id)->where('alias', 'john.test@kolab.org')->first(); $this->assertTrue(!empty($alias)); }); } /** * Test user adding page * * @depends testList */ public function testNewUser(): void { $this->browse(function (Browser $browser) { $browser->visit(new UserList()) ->assertSeeIn('button.create-user', 'Create user') ->click('button.create-user') ->on(new UserInfo()) ->assertSeeIn('#user-info .card-title', 'New user account') ->with('@form', function (Browser $browser) { // Assert form content $browser->assertFocused('div.row:nth-child(1) input') ->assertSeeIn('div.row:nth-child(1) label', 'First name') ->assertValue('div.row:nth-child(1) input[type=text]', '') ->assertSeeIn('div.row:nth-child(2) label', 'Last name') ->assertValue('div.row:nth-child(2) input[type=text]', '') ->assertSeeIn('div.row:nth-child(3) label', 'Email') ->assertValue('div.row:nth-child(3) input[type=text]', '') - ->assertEnabled('div.row:nth-child(3) input') + ->assertEnabled('div.row:nth-child(3) input[type=text]') ->assertSeeIn('div.row:nth-child(4) label', 'Email aliases') ->assertVisible('div.row:nth-child(4) .listinput-widget') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertListInputValue([]) ->assertValue('@input', ''); }) ->assertSeeIn('div.row:nth-child(5) label', 'Password') ->assertValue('div.row:nth-child(5) input[type=password]', '') ->assertSeeIn('div.row:nth-child(6) label', 'Confirm password') ->assertValue('div.row:nth-child(6) input[type=password]', '') ->assertSeeIn('button[type=submit]', 'Submit'); // Test browser-side required fields and error handling $browser->click('button[type=submit]') ->assertFocused('#email') ->type('#email', 'invalid email') ->click('button[type=submit]') ->assertFocused('#password') ->type('#password', 'simple123') ->click('button[type=submit]') ->assertFocused('#password_confirmation') ->type('#password_confirmation', 'simple') ->click('button[type=submit]'); }) ->with(new Toast(Toast::TYPE_ERROR), function (Browser $browser) { $browser->assertToastTitle('Error') ->assertToastMessage('Form validation error') ->closeToast(); }) ->with('@form', function (Browser $browser) { $browser->assertSeeIn('#email + .invalid-feedback', 'The specified email is invalid.') ->assertSeeIn('#password + .invalid-feedback', 'The password confirmation does not match.'); }); // Test form error handling (aliases) $browser->with('@form', function (Browser $browser) { - $browser->type('#email', 'john.rambo@kolab.org') + $browser->type('#email', 'julia.roberts@kolab.org') ->type('#password_confirmation', 'simple123') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->addListEntry('invalid address'); }) ->click('button[type=submit]'); }) ->with(new Toast(Toast::TYPE_ERROR), function (Browser $browser) { $browser->assertToastTitle('Error') ->assertToastMessage('Form validation error') ->closeToast(); }) ->with('@form', function (Browser $browser) { $browser->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertFormError(1, 'The specified alias is invalid.', false); }); }); // Successful account creation $browser->with('@form', function (Browser $browser) { $browser->with(new ListInput('#aliases'), function (Browser $browser) { $browser->removeListEntry(1) - ->addListEntry('john.rambo2@kolab.org'); + ->addListEntry('julia.roberts2@kolab.org'); }) ->click('button[type=submit]'); }) ->with(new Toast(Toast::TYPE_SUCCESS), function (Browser $browser) { $browser->assertToastTitle('') ->assertToastMessage('User created successfully') ->closeToast(); - }); - - // TODO: assert redirect to users list + }) + // check redirection to users list + ->waitForLocation('/users') + ->on(new UserList()) + ->whenAvailable('@table', function (Browser $browser) { +// TODO: This will not work until we handle entitlements on user creation +// $browser->assertElementsCount('tbody tr', 3) +// ->assertSeeIn('tbody tr:nth-child(3) a', 'julia.roberts@kolab.org'); + }); - $john = User::where('email', 'john.rambo@kolab.org')->first(); - $alias = UserAlias::where('user_id', $john->id)->where('alias', 'john.rambo2@kolab.org')->first(); + $julia = User::where('email', 'julia.roberts@kolab.org')->first(); + $alias = UserAlias::where('user_id', $julia->id)->where('alias', 'julia.roberts2@kolab.org')->first(); $this->assertTrue(!empty($alias)); }); } + + /** + * Test user delete + * + * @depends testNewUser + */ + public function testDeleteUser(): void + { + // First create a new user + $john = $this->getTestUser('john@kolab.org'); + $julia = $this->getTestUser('julia.roberts@kolab.org'); + $package_kolab = \App\Package::where('title', 'kolab')->first(); + $john->assignPackage($package_kolab, $julia); + + // Test deleting non-controller user + $this->browse(function (Browser $browser) { + $browser->visit(new UserList()) + ->whenAvailable('@table', function (Browser $browser) { + $browser->assertElementsCount('tbody tr', 4) + ->assertSeeIn('tbody tr:nth-child(3) a', 'julia.roberts@kolab.org') + ->click('tbody tr:nth-child(3) button.button-delete'); + }) + ->with(new Dialog('#delete-warning'), function (Browser $browser) { + $browser->assertSeeIn('@title', 'Delete julia.roberts@kolab.org') + ->assertFocused('@button-cancel') + ->assertSeeIn('@button-cancel', 'Cancel') + ->assertSeeIn('@button-action', 'Delete') + ->click('@button-cancel'); + }) + ->whenAvailable('@table', function (Browser $browser) { + $browser->click('tbody tr:nth-child(3) button.button-delete'); + }) + ->with(new Dialog('#delete-warning'), function (Browser $browser) { + $browser->click('@button-action'); + }) + ->with(new Toast(Toast::TYPE_SUCCESS), function (Browser $browser) { + $browser->assertToastTitle('') + ->assertToastMessage('User deleted successfully') + ->closeToast(); + }) + ->with('@table', function (Browser $browser) { + $browser->assertElementsCount('tbody tr', 3) + ->assertSeeIn('tbody tr:nth-child(1) a', 'jack@kolab.org') + ->assertSeeIn('tbody tr:nth-child(2) a', 'john@kolab.org') + ->assertSeeIn('tbody tr:nth-child(3) a', 'ned@kolab.org'); + }); + + $julia = User::where('email', 'julia.roberts@kolab.org')->first(); + $this->assertTrue(empty($julia)); + + // Test clicking Delete on the controller record redirects to /profile/delete + $browser + ->with('@table', function (Browser $browser) { + $browser->click('tbody tr:nth-child(2) button.button-delete'); + }) + ->waitForLocation('/profile/delete'); + }); + + // Test that non-controller user cannot see/delete himself on the users list + // Note: Access to /profile/delete page is tested in UserProfileTest.php + $this->browse(function (Browser $browser) { + $browser->visit('/logout') + ->on(new Home()) + ->submitLogon('jack@kolab.org', 'simple123', true) + ->visit(new UserList()) + ->whenAvailable('@table', function (Browser $browser) { + $browser->assertElementsCount('tbody tr', 0); + }); + }); + + // Test that controller user (Ned) can see/delete all the users ??? + $this->browse(function (Browser $browser) { + $browser->visit('/logout') + ->on(new Home()) + ->submitLogon('ned@kolab.org', 'simple123', true) + ->visit(new UserList()) + ->whenAvailable('@table', function (Browser $browser) { + $browser->assertElementsCount('tbody tr', 3) + ->assertElementsCount('tbody button.button-delete', 3); + }); + + // TODO: Test the delete action in details + }); + + // TODO: Test what happens with the logged in user session after he's been deleted by another user + } } diff --git a/src/tests/DuskTestCase.php b/src/tests/DuskTestCase.php index 4368a402..9192d0f4 100644 --- a/src/tests/DuskTestCase.php +++ b/src/tests/DuskTestCase.php @@ -1,142 +1,142 @@ where('namespace', $name)->first(); if (!$domain) { return; } - $job = new \App\Jobs\DomainDelete($domain); + $job = new \App\Jobs\DomainDelete($domain->id); $job->handle(); $domain->forceDelete(); } protected function deleteTestUser($email) { Queue::fake(); $user = User::withTrashed()->where('email', $email)->first(); if (!$user) { return; } $job = new \App\Jobs\UserDelete($user->id); $job->handle(); $user->forceDelete(); } /** * Get Domain object by namespace, create it if needed. * Skip LDAP jobs. */ protected function getTestDomain($name, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); return Domain::firstOrCreate(['namespace' => $name], $attrib); } /** * Get User object by email, create it if needed. * Skip LDAP jobs. */ protected function getTestUser($email, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); return User::firstOrCreate(['email' => $email], $attrib); } /** * Prepare for Dusk test execution. * * @beforeClass * @return void */ public static function prepare() { static::startChromeDriver(); } /** * Create the RemoteWebDriver instance. * * @return \Facebook\WebDriver\Remote\RemoteWebDriver */ protected function driver() { $options = (new ChromeOptions())->addArguments([ '--lang=en_US', '--disable-gpu', '--headless', '--window-size=1280,720', ]); // For file download handling $prefs = [ 'profile.default_content_settings.popups' => 0, 'download.default_directory' => __DIR__ . '/Browser/downloads', ]; $options->setExperimentalOption('prefs', $prefs); if (getenv('TESTS_MODE') == 'phone') { // Fake User-Agent string for mobile mode $ua = 'Mozilla/5.0 (Linux; Android 4.0.4; Galaxy Nexus Build/IMM76B) AppleWebKit/537.36' . ' (KHTML, like Gecko) Chrome/60.0.3112.90 Mobile Safari/537.36'; $options->setExperimentalOption('mobileEmulation', ['userAgent' => $ua]); $options->addArguments(['--window-size=375,667']); } elseif (getenv('TESTS_MODE') == 'tablet') { // Fake User-Agent string for mobile mode $ua = 'Mozilla/5.0 (Linux; Android 6.0.1; vivo 1603 Build/MMB29M) AppleWebKit/537.36 ' . ' (KHTML, like Gecko) Chrome/58.0.3029.83 Mobile Safari/537.36'; $options->setExperimentalOption('mobileEmulation', ['userAgent' => $ua]); $options->addArguments(['--window-size=800,640']); } else { $options->addArguments(['--window-size=1280,720']); } // Make sure downloads dir exists and is empty if (!file_exists(__DIR__ . '/Browser/downloads')) { mkdir(__DIR__ . '/Browser/downloads', 0777, true); } else { foreach (glob(__DIR__ . '/Browser/downloads/*') as $file) { @unlink($file); } } return RemoteWebDriver::create( 'http://localhost:9515', DesiredCapabilities::chrome()->setCapability( ChromeOptions::CAPABILITY, $options ) ); } /** * Replace Dusk's Browser with our (extended) Browser */ protected function newBrowser($driver) { return new Browser($driver); } } diff --git a/src/tests/Feature/Controller/DomainsTest.php b/src/tests/Feature/Controller/DomainsTest.php index 88654c88..8bc22292 100644 --- a/src/tests/Feature/Controller/DomainsTest.php +++ b/src/tests/Feature/Controller/DomainsTest.php @@ -1,147 +1,177 @@ deleteTestUser('test1@domainscontroller.com'); $this->deleteTestDomain('domainscontroller.com'); } public function tearDown(): void { $this->deleteTestUser('test1@domainscontroller.com'); $this->deleteTestDomain('domainscontroller.com'); parent::tearDown(); } /** * Test domain confirm request */ public function testConfirm(): void { $sku_domain = Sku::where('title', 'domain')->first(); + $john = $this->getTestUser('john@kolab.org'); + $ned = $this->getTestUser('ned@kolab.org'); $user = $this->getTestUser('test1@domainscontroller.com'); $domain = $this->getTestDomain('domainscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); - // No entitlement (user has no access to this domain), expect 403 - $response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}/confirm"); - $response->assertStatus(403); - Entitlement::create([ 'owner_id' => $user->id, 'wallet_id' => $user->wallets()->first()->id, 'sku_id' => $sku_domain->id, 'entitleable_id' => $domain->id, 'entitleable_type' => Domain::class ]); $response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}/confirm"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals('error', $json['status']); $domain->status |= Domain::STATUS_CONFIRMED; $domain->save(); $response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}/confirm"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals('success', $json['status']); - $this->assertEquals('Domain verified successfully', $json['message']); + $this->assertEquals('Domain verified successfully.', $json['message']); + + // Not authorized access + $response = $this->actingAs($john)->get("api/v4/domains/{$domain->id}/confirm"); + $response->assertStatus(403); + + // Authorized access by additional account controller + $domain = $this->getTestDomain('kolab.org'); + $response = $this->actingAs($ned)->get("api/v4/domains/{$domain->id}/confirm"); + $response->assertStatus(200); } /** * Test fetching domains list */ public function testIndex(): void { // User with no domains $user = $this->getTestUser('test1@domainscontroller.com'); $response = $this->actingAs($user)->get("api/v4/domains"); $response->assertStatus(200); $json = $response->json(); $this->assertSame([], $json); // User with custom domain(s) - $user = $this->getTestUser('john@kolab.org'); + $john = $this->getTestUser('john@kolab.org'); + $ned = $this->getTestUser('ned@kolab.org'); - $response = $this->actingAs($user)->get("api/v4/domains"); + $response = $this->actingAs($john)->get("api/v4/domains"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertCount(1, $json); + $this->assertSame('kolab.org', $json[0]['namespace']); + + $response = $this->actingAs($ned)->get("api/v4/domains"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(1, $json); $this->assertSame('kolab.org', $json[0]['namespace']); } /** * Test fetching domain info */ public function testShow(): void { $sku_domain = Sku::where('title', 'domain')->first(); $user = $this->getTestUser('test1@domainscontroller.com'); $domain = $this->getTestDomain('domainscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); - // No entitlement (user has no access to this domain), expect 403 - $response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}"); - $response->assertStatus(403); - Entitlement::create([ 'owner_id' => $user->id, 'wallet_id' => $user->wallets()->first()->id, 'sku_id' => $sku_domain->id, 'entitleable_id' => $domain->id, 'entitleable_type' => Domain::class ]); $response = $this->actingAs($user)->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']); $this->assertTrue($json['confirmed'] === false); $this->assertSame($domain->hash(Domain::HASH_TEXT), $json['hash_text']); $this->assertSame($domain->hash(Domain::HASH_CNAME), $json['hash_cname']); $this->assertSame($domain->hash(Domain::HASH_CODE), $json['hash_code']); $this->assertCount(4, $json['config']); $this->assertTrue(strpos(implode("\n", $json['config']), $domain->namespace) !== false); $this->assertCount(8, $json['dns']); $this->assertTrue(strpos(implode("\n", $json['dns']), $domain->namespace) !== false); $this->assertTrue(strpos(implode("\n", $json['dns']), $domain->hash()) !== false); + + $john = $this->getTestUser('john@kolab.org'); + $ned = $this->getTestUser('ned@kolab.org'); + $jack = $this->getTestUser('jack@kolab.org'); + + // Not authorized - Other account domain + $response = $this->actingAs($john)->get("api/v4/domains/{$domain->id}"); + $response->assertStatus(403); + + $domain = $this->getTestDomain('kolab.org'); + + // Ned is an additional controller on kolab.org's wallet + $response = $this->actingAs($ned)->get("api/v4/domains/{$domain->id}"); + $response->assertStatus(200); + + // Jack has no entitlement/control over kolab.org + $response = $this->actingAs($jack)->get("api/v4/domains/{$domain->id}"); + $response->assertStatus(403); } } diff --git a/src/tests/Feature/Controller/UsersTest.php b/src/tests/Feature/Controller/UsersTest.php index 69b36872..021c4324 100644 --- a/src/tests/Feature/Controller/UsersTest.php +++ b/src/tests/Feature/Controller/UsersTest.php @@ -1,537 +1,737 @@ deleteTestUser('UsersControllerTest1@userscontroller.com'); + $this->deleteTestUser('UsersControllerTest2@userscontroller.com'); + $this->deleteTestUser('UsersControllerTest3@userscontroller.com'); $this->deleteTestUser('UserEntitlement2A@UserEntitlement.com'); $this->deleteTestUser('john2.doe2@kolab.org'); $this->deleteTestDomain('userscontroller.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); + $this->deleteTestUser('UsersControllerTest2@userscontroller.com'); + $this->deleteTestUser('UsersControllerTest3@userscontroller.com'); $this->deleteTestUser('UserEntitlement2A@UserEntitlement.com'); $this->deleteTestUser('john2.doe2@kolab.org'); $this->deleteTestDomain('userscontroller.com'); parent::tearDown(); } /** * Test fetching current user info (/api/auth/info) */ public function testInfo(): void { $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $domain = $this->getTestDomain('userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); $response = $this->actingAs($user)->get("api/auth/info"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals($user->id, $json['id']); $this->assertEquals($user->email, $json['email']); $this->assertEquals(User::STATUS_NEW, $json['status']); $this->assertTrue(is_array($json['statusInfo'])); $this->assertTrue(is_array($json['settings'])); $this->assertTrue(is_array($json['aliases'])); // Note: Details of the content are tested in testUserResponse() } + /** + * Test user deleting (DELETE /api/v4/users/) + */ + public function testDestroy(): void + { + // First create some users/accounts to delete + $package_kolab = \App\Package::where('title', 'kolab')->first(); + $package_domain = \App\Package::where('title', 'domain-hosting')->first(); + + $john = $this->getTestUser('john@kolab.org'); + $user1 = $this->getTestUser('UsersControllerTest1@userscontroller.com'); + $user2 = $this->getTestUser('UsersControllerTest2@userscontroller.com'); + $user3 = $this->getTestUser('UsersControllerTest3@userscontroller.com'); + $domain = $this->getTestDomain('userscontroller.com', [ + 'status' => Domain::STATUS_NEW, + 'type' => Domain::TYPE_PUBLIC, + ]); + $user1->assignPackage($package_kolab); + $domain->assignPackage($package_domain, $user1); + $user1->assignPackage($package_kolab, $user2); + $user1->assignPackage($package_kolab, $user3); + + // Test unauth access + $response = $this->delete("api/v4/users/{$user2->id}"); + $response->assertStatus(401); + + // Test access to other user/account + $response = $this->actingAs($john)->delete("api/v4/users/{$user2->id}"); + $response->assertStatus(403); + $response = $this->actingAs($john)->delete("api/v4/users/{$user1->id}"); + $response->assertStatus(403); + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertSame("Access denied", $json['message']); + $this->assertCount(2, $json); + + // Test that non-controller cannot remove himself + $response = $this->actingAs($user3)->delete("api/v4/users/{$user3->id}"); + $response->assertStatus(403); + + // Test removing a non-controller user + $response = $this->actingAs($user1)->delete("api/v4/users/{$user3->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertEquals('success', $json['status']); + $this->assertEquals('User deleted successfully.', $json['message']); + + // Test removing self (an account with users) + $response = $this->actingAs($user1)->delete("api/v4/users/{$user1->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertEquals('success', $json['status']); + $this->assertEquals('User deleted successfully.', $json['message']); + } + + /** + * Test user deleting (DELETE /api/v4/users/) + */ + public function testDestroyByController(): void + { + // Create an account with additional controller - $user2 + $package_kolab = \App\Package::where('title', 'kolab')->first(); + $package_domain = \App\Package::where('title', 'domain-hosting')->first(); + $user1 = $this->getTestUser('UsersControllerTest1@userscontroller.com'); + $user2 = $this->getTestUser('UsersControllerTest2@userscontroller.com'); + $user3 = $this->getTestUser('UsersControllerTest3@userscontroller.com'); + $domain = $this->getTestDomain('userscontroller.com', [ + 'status' => Domain::STATUS_NEW, + 'type' => Domain::TYPE_PUBLIC, + ]); + $user1->assignPackage($package_kolab); + $domain->assignPackage($package_domain, $user1); + $user1->assignPackage($package_kolab, $user2); + $user1->assignPackage($package_kolab, $user3); + $user1->wallets()->first()->addController($user2); + + // TODO/FIXME: + // For now controller can delete himself, as well as + // the whole account he has control to, including the owner + // Probably he should not be able to do either of those + // However, this is not 0-regression scenario as we + // do not fully support additional controllers. + + //$response = $this->actingAs($user2)->delete("api/v4/users/{$user2->id}"); + //$response->assertStatus(403); + + $response = $this->actingAs($user2)->delete("api/v4/users/{$user3->id}"); + $response->assertStatus(200); + + $response = $this->actingAs($user2)->delete("api/v4/users/{$user1->id}"); + $response->assertStatus(200); + + // Note: More detailed assertions in testDestroy() above + + $this->assertTrue($user1->fresh()->trashed()); + $this->assertTrue($user2->fresh()->trashed()); + $this->assertTrue($user3->fresh()->trashed()); + } + + /** + * Test user listing (GET /api/v4/users) + */ public function testIndex(): void { - // TODO - $this->markTestIncomplete(); + // Test unauth access + $response = $this->get("api/v4/users"); + $response->assertStatus(401); + + $jack = $this->getTestUser('jack@kolab.org'); + $john = $this->getTestUser('john@kolab.org'); + $ned = $this->getTestUser('ned@kolab.org'); + + $response = $this->actingAs($jack)->get("/api/v4/users"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertCount(0, $json); + + $response = $this->actingAs($john)->get("/api/v4/users"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertCount(3, $json); + $this->assertSame($jack->email, $json[0]['email']); + $this->assertSame($john->email, $json[1]['email']); + $this->assertSame($ned->email, $json[2]['email']); + + $response = $this->actingAs($ned)->get("/api/v4/users"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertCount(3, $json); + $this->assertSame($jack->email, $json[0]['email']); + $this->assertSame($john->email, $json[1]['email']); + $this->assertSame($ned->email, $json[2]['email']); } /** * Test /api/auth/login */ public function testLogin(): string { // Request with no data $response = $this->post("api/auth/login", []); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertArrayHasKey('email', $json['errors']); $this->assertArrayHasKey('password', $json['errors']); // Request with invalid password $post = ['email' => 'john@kolab.org', 'password' => 'wrong']; $response = $this->post("api/auth/login", $post); $response->assertStatus(401); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame('Invalid username or password.', $json['message']); // Valid user+password $post = ['email' => 'john@kolab.org', 'password' => 'simple123']; $response = $this->post("api/auth/login", $post); $json = $response->json(); $response->assertStatus(200); $this->assertTrue(!empty($json['access_token'])); $this->assertEquals(\config('jwt.ttl') * 60, $json['expires_in']); $this->assertEquals('bearer', $json['token_type']); return $json['access_token']; } /** * Test /api/auth/logout * * @depends testLogin */ public function testLogout($token): void { // Request with no token, testing that it requires auth $response = $this->post("api/auth/logout"); $response->assertStatus(401); // Test the same using JSON mode $response = $this->json('POST', "api/auth/logout", []); $response->assertStatus(401); // Request with valid token $response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])->post("api/auth/logout"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals('success', $json['status']); - $this->assertEquals('Successfully logged out', $json['message']); + $this->assertEquals('Successfully logged out.', $json['message']); // Check if it really destroyed the token? $response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])->get("api/auth/info"); $response->assertStatus(401); } public function testRefresh(): void { // TODO $this->markTestIncomplete(); } public function testStatusInfo(): void { $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $domain = $this->getTestDomain('userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); $user->status = User::STATUS_NEW; $user->save(); $result = UsersController::statusInfo($user); $this->assertSame('new', $result['status']); $this->assertCount(3, $result['process']); $this->assertSame('user-new', $result['process'][0]['label']); $this->assertSame(true, $result['process'][0]['state']); $this->assertSame('user-ldap-ready', $result['process'][1]['label']); $this->assertSame(false, $result['process'][1]['state']); $this->assertSame('user-imap-ready', $result['process'][2]['label']); $this->assertSame(false, $result['process'][2]['state']); $user->status |= User::STATUS_LDAP_READY | User::STATUS_IMAP_READY; $user->save(); $result = UsersController::statusInfo($user); $this->assertSame('new', $result['status']); $this->assertCount(3, $result['process']); $this->assertSame('user-new', $result['process'][0]['label']); $this->assertSame(true, $result['process'][0]['state']); $this->assertSame('user-ldap-ready', $result['process'][1]['label']); $this->assertSame(true, $result['process'][1]['state']); $this->assertSame('user-imap-ready', $result['process'][2]['label']); $this->assertSame(true, $result['process'][2]['state']); $user->status |= User::STATUS_ACTIVE; $user->save(); $domain->status |= Domain::STATUS_VERIFIED; $domain->type = Domain::TYPE_EXTERNAL; $domain->save(); $result = UsersController::statusInfo($user); $this->assertSame('active', $result['status']); $this->assertCount(7, $result['process']); $this->assertSame('user-new', $result['process'][0]['label']); $this->assertSame(true, $result['process'][0]['state']); $this->assertSame('user-ldap-ready', $result['process'][1]['label']); $this->assertSame(true, $result['process'][1]['state']); $this->assertSame('user-imap-ready', $result['process'][2]['label']); $this->assertSame(true, $result['process'][2]['state']); $this->assertSame('domain-new', $result['process'][3]['label']); $this->assertSame(true, $result['process'][3]['state']); $this->assertSame('domain-ldap-ready', $result['process'][4]['label']); $this->assertSame(false, $result['process'][4]['state']); $this->assertSame('domain-verified', $result['process'][5]['label']); $this->assertSame(true, $result['process'][5]['state']); $this->assertSame('domain-confirmed', $result['process'][6]['label']); $this->assertSame(false, $result['process'][6]['state']); $user->status |= User::STATUS_DELETED; $user->save(); $result = UsersController::statusInfo($user); $this->assertSame('deleted', $result['status']); } /** * Test user data response used in show and info actions */ public function testUserResponse(): void { $user = $this->getTestUser('john@kolab.org'); - + $wallet = $user->wallets()->first(); $result = $this->invokeMethod(new UsersController(), 'userResponse', [$user]); $this->assertEquals($user->id, $result['id']); $this->assertEquals($user->email, $result['email']); $this->assertEquals($user->status, $result['status']); $this->assertTrue(is_array($result['statusInfo'])); $this->assertTrue(is_array($result['aliases'])); $this->assertCount(1, $result['aliases']); $this->assertSame('john.doe@kolab.org', $result['aliases'][0]); $this->assertTrue(is_array($result['settings'])); $this->assertSame('US', $result['settings']['country']); $this->assertSame('USD', $result['settings']['currency']); + + $this->assertTrue(is_array($result['accounts'])); + $this->assertTrue(is_array($result['wallets'])); + $this->assertCount(0, $result['accounts']); + $this->assertCount(1, $result['wallets']); + $this->assertSame($wallet->id, $result['wallet']['id']); + + $ned = $this->getTestUser('ned@kolab.org'); + $ned_wallet = $ned->wallets()->first(); + $result = $this->invokeMethod(new UsersController(), 'userResponse', [$ned]); + + $this->assertEquals($ned->id, $result['id']); + $this->assertEquals($ned->email, $result['email']); + $this->assertTrue(is_array($result['accounts'])); + $this->assertTrue(is_array($result['wallets'])); + $this->assertCount(1, $result['accounts']); + $this->assertCount(1, $result['wallets']); + $this->assertSame($wallet->id, $result['wallet']['id']); + $this->assertSame($wallet->id, $result['accounts'][0]['id']); + $this->assertSame($ned_wallet->id, $result['wallets'][0]['id']); } /** * Test fetching user data/profile (GET /api/v4/users/) */ public function testShow(): void { $userA = $this->getTestUser('UserEntitlement2A@UserEntitlement.com'); // Test getting profile of self $response = $this->actingAs($userA, 'api')->get("/api/v4/users/{$userA->id}"); $json = $response->json(); $response->assertStatus(200); $this->assertEquals($userA->id, $json['id']); $this->assertEquals($userA->email, $json['email']); $this->assertTrue(is_array($json['statusInfo'])); $this->assertTrue(is_array($json['settings'])); $this->assertTrue(is_array($json['aliases'])); + $john = $this->getTestUser('john@kolab.org'); + $jack = $this->getTestUser('jack@kolab.org'); + $ned = $this->getTestUser('ned@kolab.org'); + // Test unauthorized access to a profile of other user - $user = $this->getTestUser('jack@kolab.org'); - $response = $this->actingAs($user)->get("/api/v4/users/{$userA->id}"); + $response = $this->actingAs($jack)->get("/api/v4/users/{$userA->id}"); $response->assertStatus(403); - // TODO: Test authorized access to a profile of other user - $this->markTestIncomplete(); + // Test authorized access to a profile of other user + // Ned: Additional account controller + $response = $this->actingAs($ned)->get("/api/v4/users/{$john->id}"); + $response->assertStatus(200); + $response = $this->actingAs($ned)->get("/api/v4/users/{$jack->id}"); + $response->assertStatus(200); + + // John: Account owner + $response = $this->actingAs($john)->get("/api/v4/users/{$jack->id}"); + $response->assertStatus(200); + $response = $this->actingAs($john)->get("/api/v4/users/{$ned->id}"); + $response->assertStatus(200); } /** * Test user creation (POST /api/v4/users) */ public function testStore(): void { $jack = $this->getTestUser('jack@kolab.org'); $john = $this->getTestUser('john@kolab.org'); // Test empty request $response = $this->actingAs($john)->post("/api/v4/users", []); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame("The email field is required.", $json['errors']['email']); $this->assertSame("The password field is required.", $json['errors']['password'][0]); $this->assertCount(2, $json); // Test access by user not being a wallet controller $post = ['first_name' => 'Test']; $response = $this->actingAs($jack)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(403); $this->assertSame('error', $json['status']); $this->assertSame("Access denied", $json['message']); $this->assertCount(2, $json); // Test some invalid data $post = ['password' => '12345678', 'email' => 'invalid']; $response = $this->actingAs($john)->post("/api/v4/users", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); $this->assertSame('The password confirmation does not match.', $json['errors']['password'][0]); $this->assertSame('The specified email is invalid.', $json['errors']['email']); // Test existing user email $post = [ 'password' => 'simple', 'password_confirmation' => 'simple', 'first_name' => 'John2', 'last_name' => 'Doe2', 'email' => 'jack.daniels@kolab.org', ]; $response = $this->actingAs($john)->post("/api/v4/users", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); $this->assertSame('The specified email is not available.', $json['errors']['email']); // Test full user data $post = [ 'password' => 'simple', 'password_confirmation' => 'simple', 'first_name' => 'John2', 'last_name' => 'Doe2', 'email' => 'john2.doe2@kolab.org', 'aliases' => ['useralias1@kolab.org', 'useralias2@kolab.org'] ]; $response = $this->actingAs($john)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); - $this->assertSame("User created successfully", $json['message']); + $this->assertSame("User created successfully.", $json['message']); $this->assertCount(2, $json); $user = User::where('email', 'john2.doe2@kolab.org')->first(); $this->assertInstanceOf(User::class, $user); $this->assertSame('John2', $user->getSetting('first_name')); $this->assertSame('Doe2', $user->getSetting('last_name')); $aliases = $user->aliases()->orderBy('alias')->get(); $this->assertCount(2, $aliases); $this->assertSame('useralias1@kolab.org', $aliases[0]->alias); $this->assertSame('useralias2@kolab.org', $aliases[1]->alias); // TODO: Test assigning a package to new user + // TODO: Test the wallet to which the new user should be assigned to + + // Test acting as account controller (not owner) + /* + // FIXME: How do we know to which wallet the new user should be assigned to? + + $this->deleteTestUser('john2.doe2@kolab.org'); + $response = $this->actingAs($ned)->post("/api/v4/users", $post); + $json = $response->json(); + + $response->assertStatus(200); + + $this->assertSame('success', $json['status']); + */ + + $this->markTestIncomplete(); } /** * Test user update (PUT /api/v4/users/) */ public function testUpdate(): void { $userA = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $jack = $this->getTestUser('jack@kolab.org'); + $john = $this->getTestUser('john@kolab.org'); + $ned = $this->getTestUser('ned@kolab.org'); $domain = $this->getTestDomain( 'userscontroller.com', ['status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL] ); // Test unauthorized update of other user profile $response = $this->actingAs($jack)->get("/api/v4/users/{$userA->id}", []); $response->assertStatus(403); + // Test authorized update of account owner by account controller + $response = $this->actingAs($ned)->get("/api/v4/users/{$john->id}", []); + $response->assertStatus(200); + // Test updating of self (empty request) $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); - $this->assertSame("User data updated successfully", $json['message']); + $this->assertSame("User data updated successfully.", $json['message']); $this->assertCount(2, $json); // Test some invalid data $post = ['password' => '12345678', 'currency' => 'invalid']; $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); $this->assertSame('The password confirmation does not match.', $json['errors']['password'][0]); $this->assertSame('The currency must be 3 characters.', $json['errors']['currency'][0]); // Test full profile update including password $post = [ 'password' => 'simple', 'password_confirmation' => 'simple', 'first_name' => 'John2', 'last_name' => 'Doe2', 'phone' => '+123 123 123', 'external_email' => 'external@gmail.com', 'billing_address' => 'billing', 'country' => 'CH', 'currency' => 'CHF', 'aliases' => ['useralias1@' . \config('app.domain'), 'useralias2@' . \config('app.domain')] ]; $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); - $this->assertSame("User data updated successfully", $json['message']); + $this->assertSame("User data updated successfully.", $json['message']); $this->assertCount(2, $json); $this->assertTrue($userA->password != $userA->fresh()->password); unset($post['password'], $post['password_confirmation'], $post['aliases']); foreach ($post as $key => $value) { $this->assertSame($value, $userA->getSetting($key)); } $aliases = $userA->aliases()->orderBy('alias')->get(); $this->assertCount(2, $aliases); $this->assertSame('useralias1@' . \config('app.domain'), $aliases[0]->alias); $this->assertSame('useralias2@' . \config('app.domain'), $aliases[1]->alias); // Test unsetting values $post = [ 'first_name' => '', 'last_name' => '', 'phone' => '', 'external_email' => '', 'billing_address' => '', 'country' => '', 'currency' => '', 'aliases' => ['useralias2@' . \config('app.domain')] ]; $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); - $this->assertSame("User data updated successfully", $json['message']); + $this->assertSame("User data updated successfully.", $json['message']); $this->assertCount(2, $json); unset($post['aliases']); foreach ($post as $key => $value) { $this->assertNull($userA->getSetting($key)); } $aliases = $userA->aliases()->get(); $this->assertCount(1, $aliases); $this->assertSame('useralias2@' . \config('app.domain'), $aliases[0]->alias); // Test error on setting an alias to other user's domain // and missing password confirmation $post = [ 'password' => 'simple123', 'aliases' => ['useralias2@' . \config('app.domain'), 'useralias1@kolab.org'] ]; $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertCount(1, $json['errors']['aliases']); $this->assertSame("The specified domain is not available.", $json['errors']['aliases'][1]); $this->assertSame("The password confirmation does not match.", $json['errors']['password'][0]); + // Test authorized update of other user + $response = $this->actingAs($ned)->get("/api/v4/users/{$jack->id}", []); + $response->assertStatus(200); + // TODO: Test error on aliases with invalid/non-existing/other-user's domain - // TODO: Test authorized update of other user - $this->markTestIncomplete(); } /** * List of alias validation cases for testValidateEmail() * * @return array Arguments for testValidateEmail() */ public function dataValidateEmail(): array { $this->refreshApplication(); $public_domains = Domain::getPublicDomains(); $domain = reset($public_domains); $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); return [ // Invalid format ["$domain", $john, true, 'The specified alias is invalid.'], [".@$domain", $john, true, 'The specified alias is invalid.'], ["test123456@localhost", $john, true, 'The specified domain is invalid.'], ["test123456@unknown-domain.org", $john, true, 'The specified domain is invalid.'], ["$domain", $john, false, 'The specified email is invalid.'], [".@$domain", $john, false, 'The specified email is invalid.'], // forbidden local part on public domains ["admin@$domain", $john, true, 'The specified alias is not available.'], ["administrator@$domain", $john, true, 'The specified alias is not available.'], // forbidden (other user's domain) ["testtest@kolab.org", $user, true, 'The specified domain is not available.'], // existing alias of other user ["jack.daniels@kolab.org", $john, true, 'The specified alias is not available.'], // existing user ["jack@kolab.org", $john, true, 'The specified alias is not available.'], // valid (user domain) ["admin@kolab.org", $john, true, null], // valid (public domain) ["test.test@$domain", $john, true, null], ]; } /** * User email/alias validation. * * Note: Technically these include unit tests, but let's keep it here for now. * FIXME: Shall we do a http request for each case? * * @dataProvider dataValidateEmail */ public function testValidateEmail($alias, $user, $is_alias, $expected_result): void { $result = $this->invokeMethod(new UsersController(), 'validateEmail', [$alias, $user, $is_alias]); $this->assertSame($expected_result, $result); } } diff --git a/src/tests/Feature/DomainTest.php b/src/tests/Feature/DomainTest.php index 455bcc28..7678a7fd 100644 --- a/src/tests/Feature/DomainTest.php +++ b/src/tests/Feature/DomainTest.php @@ -1,166 +1,194 @@ domains as $domain) { $this->deleteTestDomain($domain); } } public function tearDown(): void { foreach ($this->domains as $domain) { $this->deleteTestDomain($domain); } parent::tearDown(); } /** * Test domain creating jobs */ public function testCreateJobs(): void { // Fake the queue, assert that no jobs were pushed... $queue = Queue::fake(); $queue->assertNothingPushed(); $domain = Domain::create([ 'namespace' => 'gmail.com', 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); $queue->assertPushed(\App\Jobs\DomainCreate::class, 1); $queue->assertPushed( \App\Jobs\DomainCreate::class, function ($job) use ($domain) { $job_domain = TestCase::getObjectProperty($job, 'domain'); return $job_domain->id === $domain->id && $job_domain->namespace === $domain->namespace; } ); $job = new \App\Jobs\DomainCreate($domain); $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_PUBLIC, ]); // Public but non-active domain should not be returned $public_domains = Domain::getPublicDomains(); $this->assertNotContains('public-active.com', $public_domains); $domain = Domain::where('namespace', 'public-active.com')->first(); $domain->status = Domain::STATUS_ACTIVE; $domain->save(); // Public and active domain should be returned $public_domains = Domain::getPublicDomains(); $this->assertContains('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\DomainDelete($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()); + } } diff --git a/src/tests/Feature/EntitlementTest.php b/src/tests/Feature/EntitlementTest.php index 0f646da5..ab9a3b53 100644 --- a/src/tests/Feature/EntitlementTest.php +++ b/src/tests/Feature/EntitlementTest.php @@ -1,108 +1,110 @@ deleteTestUser('entitlement-test@kolabnow.com'); $this->deleteTestUser('entitled-user@custom-domain.com'); + $this->deleteTestDomain('custom-domain.com'); } public function tearDown(): void { $this->deleteTestUser('entitlement-test@kolabnow.com'); $this->deleteTestUser('entitled-user@custom-domain.com'); + $this->deleteTestDomain('custom-domain.com'); parent::tearDown(); } /** * Tests for User::AddEntitlement() */ public function testUserAddEntitlement(): void { $package_domain = Package::where('title', 'domain-hosting')->first(); $package_kolab = Package::where('title', 'kolab')->first(); $sku_domain = Sku::where('title', 'domain-hosting')->first(); $sku_mailbox = Sku::where('title', 'mailbox')->first(); $owner = $this->getTestUser('entitlement-test@kolabnow.com'); $user = $this->getTestUser('entitled-user@custom-domain.com'); $domain = $this->getTestDomain( 'custom-domain.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ] ); $domain->assignPackage($package_domain, $owner); $owner->assignPackage($package_kolab); $owner->assignPackage($package_kolab, $user); $wallet = $owner->wallets->first(); $this->assertCount(4, $owner->entitlements()->get()); $this->assertCount(1, $sku_domain->entitlements()->where('owner_id', $owner->id)->get()); $this->assertCount(2, $sku_mailbox->entitlements()->where('owner_id', $owner->id)->get()); $this->assertCount(9, $wallet->entitlements); $this->backdateEntitlements($owner->entitlements, Carbon::now()->subMonths(1)); $wallet->chargeEntitlements(); $this->assertTrue($wallet->fresh()->balance < 0); } public function testAddExistingEntitlement(): void { $this->markTestIncomplete(); } public function testEntitlementFunctions(): void { $user = $this->getTestUser('entitlement-test@kolabnow.com'); $package = \App\Package::where('title', 'kolab')->first(); $user->assignPackage($package); $wallet = $user->wallets()->first(); $this->assertNotNull($wallet); $sku = \App\Sku::where('title', 'mailbox')->first(); $this->assertNotNull($sku); $entitlement = Entitlement::where('owner_id', $user->id)->where('sku_id', $sku->id)->first(); $this->assertNotNull($entitlement); $e_sku = $entitlement->sku; $this->assertSame($sku->id, $e_sku->id); $e_wallet = $entitlement->wallet; $this->assertSame($wallet->id, $e_wallet->id); $e_owner = $entitlement->owner; $this->assertEquals($user->id, $e_owner->id); $e_entitleable = $entitlement->entitleable; $this->assertEquals($user->id, $e_entitleable->id); $this->assertTrue($e_entitleable instanceof \App\User); } } diff --git a/src/tests/Feature/Jobs/UserVerifyTest.php b/src/tests/Feature/Jobs/UserVerifyTest.php index 13cc00b8..545af9a1 100644 --- a/src/tests/Feature/Jobs/UserVerifyTest.php +++ b/src/tests/Feature/Jobs/UserVerifyTest.php @@ -1,53 +1,62 @@ deleteTestUser('jane@kolabnow.com'); } public function tearDown(): void { $this->deleteTestUser('jane@kolabnow.com'); parent::tearDown(); } /** * Test job handle * * @group imap */ public function testHandle(): void { Queue::fake(); $user = $this->getTestUser('jane@kolabnow.com'); // This is a valid assertion in a feature, not functional test environment. $this->assertFalse($user->isImapReady()); $this->assertFalse($user->isLdapReady()); $job = new UserCreate($user); $job->handle(); $this->assertFalse($user->isImapReady()); $this->assertTrue($user->isLdapReady()); - $job = new UserVerify($user); - $job->handle(); + for ($i = 0; $i < 10; $i++) { + $job = new UserVerify($user); + $job->handle(); + + if ($user->fresh()->isImapReady()) { + $this->assertTrue(true); + return; + } + + sleep(1); + } - $this->assertTrue($user->fresh()->isImapReady()); + $this->assertTrue(false, "Unable to verify the IMAP account is set up in time"); } } diff --git a/src/tests/Feature/SkuTest.php b/src/tests/Feature/SkuTest.php index 663983d6..1801baf7 100644 --- a/src/tests/Feature/SkuTest.php +++ b/src/tests/Feature/SkuTest.php @@ -1,94 +1,94 @@ deleteTestUser('jane@kolabnow.com'); } public function tearDown(): void { $this->deleteTestUser('jane@kolabnow.com'); parent::tearDown(); } public function testPackageEntitlements(): void { $user = $this->getTestUser('jane@kolabnow.com'); $wallet = $user->wallets()->first(); $package = Package::where('title', 'lite')->first(); $sku_mailbox = Sku::where('title', 'mailbox')->first(); $sku_storage = Sku::where('title', 'storage')->first(); $user = $user->assignPackage($package); $this->backdateEntitlements($user->fresh()->entitlements, Carbon::now()->subMonths(1)); $wallet->chargeEntitlements(); $this->assertTrue($wallet->balance < 0); } public function testSkuEntitlements(): void { - $this->assertCount(2, Sku::where('title', 'mailbox')->first()->entitlements); + $this->assertCount(3, Sku::where('title', 'mailbox')->first()->entitlements); } public function testSkuPackages(): void { $this->assertCount(2, Sku::where('title', 'mailbox')->first()->packages); } public function testSkuHandlerDomainHosting(): void { $sku = Sku::where('title', 'domain-hosting')->first(); $entitlement = $sku->entitlements->first(); $this->assertSame( Handlers\DomainHosting::entitleableClass(), $entitlement->entitleable_type ); } public function testSkuHandlerMailbox(): void { $sku = Sku::where('title', 'mailbox')->first(); $entitlement = $sku->entitlements->first(); $this->assertSame( Handlers\Mailbox::entitleableClass(), $entitlement->entitleable_type ); } public function testSkuHandlerStorage(): void { $sku = Sku::where('title', 'storage')->first(); $entitlement = $sku->entitlements->first(); $this->assertSame( Handlers\Storage::entitleableClass(), $entitlement->entitleable_type ); } } diff --git a/src/tests/Feature/UserTest.php b/src/tests/Feature/UserTest.php index baf379dc..20eb00b0 100644 --- a/src/tests/Feature/UserTest.php +++ b/src/tests/Feature/UserTest.php @@ -1,263 +1,365 @@ deleteTestUser('user-create-test@' . \config('app.domain')); + $this->deleteTestUser('userdeletejob@kolabnow.com'); $this->deleteTestUser('UserAccountA@UserAccount.com'); $this->deleteTestUser('UserAccountB@UserAccount.com'); - $this->deleteTestUser('userdeletejob@kolabnow.com'); + $this->deleteTestUser('UserAccountC@UserAccount.com'); + $this->deleteTestDomain('UserAccount.com'); } public function tearDown(): void { $this->deleteTestUser('user-create-test@' . \config('app.domain')); + $this->deleteTestUser('userdeletejob@kolabnow.com'); $this->deleteTestUser('UserAccountA@UserAccount.com'); $this->deleteTestUser('UserAccountB@UserAccount.com'); - $this->deleteTestUser('userdeletejob@kolabnow.com'); + $this->deleteTestUser('UserAccountC@UserAccount.com'); + $this->deleteTestDomain('UserAccount.com'); parent::tearDown(); } /** * Tests for User::assignPackage() */ public function testAssignPackage(): void { $this->markTestIncomplete(); } /** * Tests for User::assignPlan() */ public function testAssignPlan(): void { $this->markTestIncomplete(); } /** * Verify user creation process */ public function testUserCreateJob(): void { // Fake the queue, assert that no jobs were pushed... $queue = Queue::fake(); $queue->assertNothingPushed(); $user = User::create([ 'email' => 'user-create-test@' . \config('app.domain') ]); $queue->assertPushed(\App\Jobs\UserCreate::class, 1); $queue->assertPushed(\App\Jobs\UserCreate::class, function ($job) use ($user) { $job_user = TestCase::getObjectProperty($job, 'user'); return $job_user->id === $user->id && $job_user->email === $user->email; }); $queue->assertPushedWithChain(\App\Jobs\UserCreate::class, [ \App\Jobs\UserVerify::class, ]); /* FIXME: Looks like we can't really do detailed assertions on chained jobs Another thing to consider is if we maybe should run these jobs independently (not chained) and make sure there's no race-condition in status update $queue->assertPushed(\App\Jobs\UserVerify::class, 1); $queue->assertPushed(\App\Jobs\UserVerify::class, function ($job) use ($user) { $job_user = TestCase::getObjectProperty($job, 'user'); return $job_user->id === $user->id && $job_user->email === $user->email; }); */ } /** * Verify a wallet assigned a controller is among the accounts of the assignee. */ public function testListUserAccounts(): 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); } - /** - * Tests for User::controller() - */ - public function testController(): void + public function testAccounts(): void { - $john = $this->getTestUser('john@kolab.org'); + $this->markTestIncomplete(); + } - $this->assertSame($john->id, $john->controller()->id); + public function testCanDelete(): void + { + $this->markTestIncomplete(); + } - $jack = $this->getTestUser('jack@kolab.org'); + public function testCanRead(): void + { + $this->markTestIncomplete(); + } - $this->assertSame($john->id, $jack->controller()->id); + public function testCanUpdate(): void + { + $this->markTestIncomplete(); } /** * Tests for User::domains() */ public function testDomains(): void { $user = $this->getTestUser('john@kolab.org'); $domains = []; foreach ($user->domains() as $domain) { $domains[] = $domain->namespace; } $this->assertContains(\config('app.domain'), $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 = []; foreach ($user->domains() as $domain) { $domains[] = $domain->namespace; } $this->assertContains(\config('app.domain'), $domains); $this->assertNotContains('kolab.org', $domains); } public function testUserQuota(): void { + // TODO: This test does not test much, probably could be removed + // or moved to somewhere else, or extended with + // other entitlements() related cases. + $user = $this->getTestUser('john@kolab.org'); $storage_sku = \App\Sku::where('title', 'storage')->first(); $count = 0; foreach ($user->entitlements()->get() as $entitlement) { if ($entitlement->sku_id == $storage_sku->id) { $count += 1; } } $this->assertTrue($count == 2); } /** * Test user deletion */ - public function testUserDelete(): void + public function testDelete(): void { - $user = $this->getTestUser('userdeletejob@kolabnow.com'); + Queue::fake(); + $user = $this->getTestUser('userdeletejob@kolabnow.com'); $package = \App\Package::where('title', 'kolab')->first(); - $user->assignPackage($package); $id = $user->id; + $entitlements = \App\Entitlement::where('owner_id', $id)->get(); + $this->assertCount(4, $entitlements); + $user->delete(); + $entitlements = \App\Entitlement::where('owner_id', $id)->get(); + $this->assertCount(0, $entitlements); + $this->assertTrue($user->fresh()->trashed()); + $this->assertFalse($user->fresh()->isDeleted()); + + // Delete the user for real $job = new \App\Jobs\UserDelete($id); $job->handle(); + $this->assertTrue(User::withTrashed()->where('id', $id)->first()->isDeleted()); + $user->forceDelete(); - $entitlements = \App\Entitlement::where('owner_id', 'id')->get(); + $this->assertCount(0, User::withTrashed()->where('id', $id)->get()); - $this->assertCount(0, $entitlements); + // Test an account with users + $userA = $this->getTestUser('UserAccountA@UserAccount.com'); + $userB = $this->getTestUser('UserAccountB@UserAccount.com'); + $userC = $this->getTestUser('UserAccountC@UserAccount.com'); + $package_kolab = \App\Package::where('title', 'kolab')->first(); + $package_domain = \App\Package::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); + + $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); + $this->assertSame(4, $entitlementsA->count()); + $this->assertSame(4, $entitlementsB->count()); + $this->assertSame(4, $entitlementsC->count()); + $this->assertSame(1, $entitlementsDomain->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->assertTrue($userA->fresh()->trashed()); + $this->assertTrue($userB->fresh()->trashed()); + $this->assertTrue($domain->fresh()->trashed()); + $this->assertFalse($userA->isDeleted()); + $this->assertFalse($userB->isDeleted()); + $this->assertFalse($domain->isDeleted()); } /** * 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); // TODO: searching by external email (setting) $this->markTestIncomplete(); } /** * Tests for UserAliasesTrait::setAliases() */ public function testSetAliases(): void { Queue::fake(); $user = $this->getTestUser('UserAccountA@UserAccount.com'); $this->assertCount(0, $user->aliases->all()); // Add an alias $user->setAliases(['UserAlias1@UserAccount.com']); $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']); $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); // Remove an alias $user->setAliases(['UserAlias1@UserAccount.com']); $aliases = $user->aliases()->get(); $this->assertCount(1, $aliases); $this->assertSame('useralias1@useraccount.com', $aliases[0]['alias']); // Remove all aliases $user->setAliases([]); $this->assertCount(0, $user->aliases()->get()); // TODO: Test that the changes are propagated to ldap } /** * Tests for UserSettingsTrait::setSettings() */ public function testSetSettings(): void { $this->markTestIncomplete(); } + + /** + * Tests for User::users() + */ + public function testUsers(): void + { + $john = $this->getTestUser('john@kolab.org'); + $jack = $this->getTestUser('jack@kolab.org'); + $ned = $this->getTestUser('ned@kolab.org'); + $wallet = $john->wallets()->first(); + + $users = $john->users()->orderBy('email')->get(); + + $this->assertCount(3, $users); + $this->assertEquals($jack->id, $users[0]->id); + $this->assertEquals($john->id, $users[1]->id); + $this->assertEquals($ned->id, $users[2]->id); + $this->assertSame($wallet->id, $users[0]->wallet_id); + $this->assertSame($wallet->id, $users[1]->wallet_id); + $this->assertSame($wallet->id, $users[2]->wallet_id); + + $users = $jack->users()->orderBy('email')->get(); + + $this->assertCount(0, $users); + + $users = $ned->users()->orderBy('email')->get(); + + $this->assertCount(3, $users); + } + + public function testWallets(): void + { + $this->markTestIncomplete(); + } } diff --git a/src/tests/TestCase.php b/src/tests/TestCase.php index fc9ce538..a160fb5e 100644 --- a/src/tests/TestCase.php +++ b/src/tests/TestCase.php @@ -1,116 +1,116 @@ created_at = $targetDate; $entitlement->updated_at = $targetDate; $entitlement->save(); } } protected function deleteTestDomain($name) { Queue::fake(); $domain = Domain::withTrashed()->where('namespace', $name)->first(); if (!$domain) { return; } - $job = new \App\Jobs\DomainDelete($domain); + $job = new \App\Jobs\DomainDelete($domain->id); $job->handle(); $domain->forceDelete(); } protected function deleteTestUser($email) { Queue::fake(); $user = User::withTrashed()->where('email', $email)->first(); if (!$user) { return; } $job = new \App\Jobs\UserDelete($user->id); $job->handle(); $user->forceDelete(); } /** * Get Domain object by namespace, create it if needed. * Skip LDAP jobs. */ protected function getTestDomain($name, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); return Domain::firstOrCreate(['namespace' => $name], $attrib); } /** * Get User object by email, create it if needed. * Skip LDAP jobs. */ protected function getTestUser($email, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); $user = User::withTrashed()->where('email', $email)->first(); if (!$user) { return User::firstOrCreate(['email' => $email], $attrib); } if ($user->deleted_at) { $user->restore(); } return $user; } /** * Helper to access protected property of an object */ protected static function getObjectProperty($object, $property_name) { $reflection = new \ReflectionClass($object); $property = $reflection->getProperty($property_name); $property->setAccessible(true); return $property->getValue($object); } /** * Call protected/private method of a class. * * @param object $object Instantiated object that we will run method on. * @param string $methodName Method name to call * @param array $parameters Array of parameters to pass into method. * * @return mixed Method return. */ public function invokeMethod($object, $methodName, array $parameters = array()) { $reflection = new \ReflectionClass(get_class($object)); $method = $reflection->getMethod($methodName); $method->setAccessible(true); return $method->invokeArgs($object, $parameters); } }