diff --git a/phpcs.xml b/phpcs.xml index 0e7dd587..ac7cf195 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -1,12 +1,12 @@ - Custom ruleset for Kolab + Custom ruleset for Kolab - + src/app/ src/tests/ diff --git a/src/app/Backends/LDAP.php b/src/app/Backends/LDAP.php index d5022e91..e4ed659f 100644 --- a/src/app/Backends/LDAP.php +++ b/src/app/Backends/LDAP.php @@ -1,15 +1,16 @@ morphOne('App\Entitlement', 'entitleable'); } /** * Returns whether this domain is active. * * @return bool */ public function isActive(): bool { return $this->status & self::STATUS_ACTIVE; } /** * Returns whether this domain is confirmed the ownership of. * * @return bool */ public function isConfirmed(): bool { return $this->status & self::STATUS_CONFIRMED; } /** * Returns whether this domain is deleted. * * @return bool */ public function isDeleted(): bool { return $this->status & self::STATUS_DELETED; } /** * Returns whether this domain is registered with us. * * @return bool */ public function isExternal(): bool { return $this->type & self::TYPE_EXTERNAL; } /** * Returns whether this domain is hosted with us. * * @return bool */ public function isHosted(): bool { return $this->type & self::TYPE_HOSTED; } /** * Returns whether this domain is new. * * @return bool */ public function isNew(): bool { return $this->status & self::STATUS_NEW; } /** * Returns whether this domain is public. * * @return bool */ public function isPublic(): bool { return $this->type & self::TYPE_PUBLIC; } /** * Returns whether this domain is suspended. * * @return bool */ public function isSuspended(): bool { return $this->status & self::STATUS_SUSPENDED; } /* public function setStatusAttribute($status) { $_status = $this->status; switch ($status) { case "new": $_status += self::STATUS_NEW; break; case "active": $_status += self::STATUS_ACTIVE; $_status -= self::STATUS_NEW; break; case "confirmed": $_status += self::STATUS_CONFIRMED; $_status -= self::STATUS_NEW; break; case "suspended": $_status += self::STATUS_SUSPENDED; break; case "deleted": $_status += self::STATUS_DELETED; break; default: $_status = $status; //throw new \Exception("Invalid domain status: {$status}"); break; } $this->status = $_status; } */ } diff --git a/src/app/Entitlement.php b/src/app/Entitlement.php index 45ca0b36..c7e9da27 100644 --- a/src/app/Entitlement.php +++ b/src/app/Entitlement.php @@ -1,80 +1,81 @@ morphTo(); } /** * The SKU concerned. * * @return Sku */ public function sku() { return $this->belongsTo('App\Sku'); } /** * The owner of this entitlement. * * @return User */ public function owner() { return $this->belongsTo('App\User', 'owner_id', 'id'); } /** * The wallet this entitlement is being billed to * * @return Wallet */ public function wallet() { return $this->belongsTo('App\Wallet'); } } diff --git a/src/app/Handlers/Domain.php b/src/app/Handlers/Domain.php index d2ff3aae..c8db9f67 100644 --- a/src/app/Handlers/Domain.php +++ b/src/app/Handlers/Domain.php @@ -1,16 +1,17 @@ sku_id)->active) { return false; } return true; } } diff --git a/src/app/Handlers/DomainHosting.php b/src/app/Handlers/DomainHosting.php index 61f9ae8c..78c1d647 100644 --- a/src/app/Handlers/DomainHosting.php +++ b/src/app/Handlers/DomainHosting.php @@ -1,16 +1,17 @@ sku_id)->active) { return false; } return false; } } diff --git a/src/app/Handlers/DomainRegistration.php b/src/app/Handlers/DomainRegistration.php index 903b0c4c..ff5494a5 100644 --- a/src/app/Handlers/DomainRegistration.php +++ b/src/app/Handlers/DomainRegistration.php @@ -1,16 +1,17 @@ sku_id)->active) { return false; } return false; } } diff --git a/src/app/Handlers/Groupware.php b/src/app/Handlers/Groupware.php index 80323c75..fd6d97dc 100644 --- a/src/app/Handlers/Groupware.php +++ b/src/app/Handlers/Groupware.php @@ -1,16 +1,17 @@ sku_id)->active) { return false; } return true; } } diff --git a/src/app/Handlers/Resource.php b/src/app/Handlers/Resource.php index babc4dac..51da6b15 100644 --- a/src/app/Handlers/Resource.php +++ b/src/app/Handlers/Resource.php @@ -1,16 +1,17 @@ sku_id)->active) { return false; } return true; } } diff --git a/src/app/Handlers/SharedFolder.php b/src/app/Handlers/SharedFolder.php index afe23278..a30905a4 100644 --- a/src/app/Handlers/SharedFolder.php +++ b/src/app/Handlers/SharedFolder.php @@ -1,16 +1,17 @@ sku_id)->active) { return false; } return true; } } diff --git a/src/app/Handlers/Storage.php b/src/app/Handlers/Storage.php index c39b040b..631e9ca6 100644 --- a/src/app/Handlers/Storage.php +++ b/src/app/Handlers/Storage.php @@ -1,16 +1,17 @@ sku_id)->active) { return false; } return true; } } diff --git a/src/app/Http/Controllers/API/PasswordResetController.php b/src/app/Http/Controllers/API/PasswordResetController.php new file mode 100644 index 00000000..dcef6805 --- /dev/null +++ b/src/app/Http/Controllers/API/PasswordResetController.php @@ -0,0 +1,144 @@ +all(), ['email' => 'required|email']); + + if ($v->fails()) { + return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); + } + + // Find a user by email + $user = User::findByEmail($request->email); + + if (!$user) { + $errors = ['email' => __('validation.usernotexists')]; + return response()->json(['status' => 'error', 'errors' => $errors], 422); + } + + if (!$user->getSetting('external_email')) { + $errors = ['email' => __('validation.noextemail')]; + return response()->json(['status' => 'error', 'errors' => $errors], 422); + } + + // Generate the verification code + $code = new VerificationCode(['mode' => 'password-reset']); + $user->verificationcodes()->save($code); + + // Send email/sms message + PasswordResetEmail::dispatch($code); + + return response()->json(['status' => 'success', 'code' => $code->code]); + } + + /** + * Validation of the verification code. + * + * @param Illuminate\Http\Request HTTP request + * + * @return \Illuminate\Http\JsonResponse JSON response + */ + public function verify(Request $request) + { + // Validate the request args + $v = Validator::make( + $request->all(), + [ + 'code' => 'required', + 'short_code' => 'required', + ] + ); + + if ($v->fails()) { + return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); + } + + // Validate the verification code + $code = VerificationCode::find($request->code); + + if ( + empty($code) + || $code->isExpired() + || $code->mode !== 'password-reset' + || $code->short_code !== $request->short_code + ) { + $errors = ['short_code' => "The code is invalid or expired."]; + return response()->json(['status' => 'error', 'errors' => $errors], 422); + } + + // For last-step remember the code object, so we can delete it + // with single SQL query (->delete()) instead of two (::destroy()) + $this->code = $code; + + // Return user name and email/phone from the codes database on success + return response()->json(['status' => 'success']); + } + + /** + * Password change + * + * @param Illuminate\Http\Request HTTP request + * + * @return \Illuminate\Http\JsonResponse JSON response + */ + public function reset(Request $request) + { + // Validate the request args + $v = Validator::make( + $request->all(), + [ + 'password' => 'required|min:4|confirmed', + ] + ); + + if ($v->fails()) { + return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); + } + + $v = $this->verify($request); + if ($v->status() !== 200) { + return $v; + } + + $user = $this->code->user; + + // Change the user password + $user->setPasswordAttribute($request->password); + $user->save(); + + // Remove the verification code + $this->code->delete(); + + return UsersController::logonResponse($user); + } +} diff --git a/src/app/Http/Controllers/API/SignupController.php b/src/app/Http/Controllers/API/SignupController.php index 8878b23d..6db8ffec 100644 --- a/src/app/Http/Controllers/API/SignupController.php +++ b/src/app/Http/Controllers/API/SignupController.php @@ -1,263 +1,256 @@ all(), [ 'email' => 'required', 'name' => 'required', ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } // Validate user email (or phone) if ($error = $this->validatePhoneOrEmail($request->email, $is_phone)) { return response()->json(['status' => 'error', 'errors' => ['email' => __($error)]], 422); } // Generate the verification code $code = SignupCode::create([ 'data' => [ 'email' => $request->email, 'name' => $request->name, ] ]); // Send email/sms message if ($is_phone) { SignupVerificationSMS::dispatch($code); } else { SignupVerificationEmail::dispatch($code); } return response()->json(['status' => 'success', 'code' => $code->code]); } /** * Validation of the verification code. * * @param Illuminate\Http\Request HTTP request * * @return \Illuminate\Http\JsonResponse JSON response */ public function verify(Request $request) { // Validate the request args $v = Validator::make( $request->all(), [ 'code' => 'required', 'short_code' => 'required', ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } // Validate the verification code $code = SignupCode::find($request->code); - if (empty($code) + if ( + empty($code) || $code->isExpired() || Str::upper($request->short_code) !== Str::upper($code->short_code) ) { $errors = ['short_code' => "The code is invalid or expired."]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } // For signup last-step mode remember the code object, so we can delete it // with single SQL query (->delete()) instead of two (::destroy()) $this->code = $code; // Return user name and email/phone from the codes database on success return response()->json([ 'status' => 'success', 'email' => $code->data['email'], 'name' => $code->data['name'], ]); } /** * Finishes the signup process by creating the user account. * * @param Illuminate\Http\Request HTTP request * * @return \Illuminate\Http\JsonResponse JSON response */ public function signup(Request $request) { // Validate input $v = Validator::make( $request->all(), [ 'login' => 'required|min:2', 'password' => 'required|min:4|confirmed', ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } $login = $request->login . '@' . \config('app.domain'); // Validate login (email) if ($error = $this->validateEmail($login, true)) { return response()->json(['status' => 'error', 'errors' => ['login' => $error]], 422); } // Validate verification codes (again) $v = $this->verify($request); if ($v->status() !== 200) { return $v; } // Get user name/email from the verification code database $code_data = $v->getData(); $user_name = $code_data->name; $user_email = $code_data->email; // We allow only ASCII, so we can safely lower-case the email address $login = Str::lower($login); $user = User::create( [ 'name' => $user_name, 'email' => $login, 'password' => $request->password, ] ); // Save the external email in user settings $user->setSettings(['external_email' => $user_email]); // Remove the verification code $this->code->delete(); - $token = auth()->login($user); - - return response()->json([ - 'status' => 'success', - 'access_token' => $token, - 'token_type' => 'bearer', - 'expires_in' => Auth::guard()->factory()->getTTL() * 60, - ]); + return UsersController::logonResponse($user); } /** * Checks if the input string is a valid email address or a phone number * * @param string $email Email address or phone number * @param bool &$is_phone Will be set to True if the string is valid phone number * * @return string Error message label on validation error */ protected function validatePhoneOrEmail($input, &$is_phone = false) { $is_phone = false; return $this->validateEmail($input); // TODO: Phone number support /* if (strpos($input, '@')) { return $this->validateEmail($input); } $input = str_replace(array('-', ' '), '', $input); if (!preg_match('/^\+?[0-9]{9,12}$/', $input)) { return 'validation.noemailorphone'; } $is_phone = true; */ } /** * Email address validation * * @param string $email Email address * @param bool $signup Enables additional checks for signup mode * * @return string Error message label on validation error */ protected function validateEmail($email, $signup = false) { $v = Validator::make(['email' => $email], ['email' => 'required|email']); if ($v->fails()) { return 'validation.emailinvalid'; } list($local, $domain) = explode('@', $email); // don't allow @localhost and other no-fqdn if (strpos($domain, '.') === false) { return 'validation.emailinvalid'; } // Extended checks for an address that is supposed to become a login to Kolab if ($signup) { // Local part validation if (!preg_match('/^[A-Za-z0-9_.-]+$/', $local)) { return 'validation.emailinvalid'; } // Check if specified domain is allowed for signup if ($domain != \config('app.domain')) { return 'validation.emailinvalid'; } // Check if the local part is not one of exceptions $exceptions = '/^(admin|administrator|sales|root)$/i'; if (preg_match($exceptions, $local)) { return 'validation.emailexists'; } // Check if user with specified login already exists // TODO: Aliases if (User::where('email', $email)->first()) { return 'validation.emailexists'; } } } } diff --git a/src/app/Http/Controllers/API/UsersController.php b/src/app/Http/Controllers/API/UsersController.php index d95cd564..1f2c91b5 100644 --- a/src/app/Http/Controllers/API/UsersController.php +++ b/src/app/Http/Controllers/API/UsersController.php @@ -1,163 +1,182 @@ 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. * * The user themself, and other user entitlements. * * @return \Illuminate\Http\Response */ public function index() { $user = Auth::user(); if (!$user) { return response()->json(['error' => 'unauthorized'], 401); } $result = [$user]; $user->entitlements()->each( function ($entitlement) { $result[] = User::find($entitlement->user_id); } ); return response()->json($result); } /** * Get the authenticated User * * @return \Illuminate\Http\JsonResponse */ public function info() { return response()->json($this->guard()->user()); } /** * Get a JWT token via given credentials. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse */ public function login(Request $request) { $credentials = $request->only('email', 'password'); if ($token = $this->guard()->attempt($credentials)) { return $this->respondWithToken($token); } return response()->json(['error' => 'Unauthorized'], 401); } /** * Log the user out (Invalidate the token) * * @return \Illuminate\Http\JsonResponse */ public function logout() { $this->guard()->logout(); return response()->json(['message' => 'Successfully logged out']); } /** * 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 the specified resource. * * @param int $id The account to show information for. * * @return \Illuminate\Http\Response */ public function show($id) { $user = Auth::user(); if (!$user) { return abort(403); } $result = false; $user->entitlements()->each( function ($entitlement) { if ($entitlement->user_id == $id) { $result = true; } } ); if ($user->id == $id) { $result = true; } if (!$result) { return abort(404); } return \App\User::find($id); } /** * Get the guard to be used during authentication. * * @return \Illuminate\Contracts\Auth\Guard */ public function guard() { return Auth::guard(); } } diff --git a/src/app/Http/Controllers/API/WalletsController.php b/src/app/Http/Controllers/API/WalletsController.php index a6d4d026..94247981 100644 --- a/src/app/Http/Controllers/API/WalletsController.php +++ b/src/app/Http/Controllers/API/WalletsController.php @@ -1,95 +1,97 @@ code = $code; } /** * Determine the time at which the job should timeout. * * @return \DateTime */ public function retryUntil() { // FIXME: I think it does not make sense to continue trying after 1 hour return now()->addHours(1); } /** * Execute the job. * * @return void */ public function handle() { - Mail::to($this->code->data['email'])->send(new SignupVerification($this->code)); + $email = $this->code->user->getSetting('external_email'); + + Mail::to($email)->send(new PasswordReset($this->code)); } } diff --git a/src/app/Jobs/ProcessUserCreate.php b/src/app/Jobs/ProcessUserCreate.php index a04e3c36..dc3047c6 100644 --- a/src/app/Jobs/ProcessUserCreate.php +++ b/src/app/Jobs/ProcessUserCreate.php @@ -1,36 +1,39 @@ user = $user; } /** * Execute the job. * * @return void */ public function handle() { // TODO: Create the user in LDAP } } diff --git a/src/app/Jobs/ProcessUserDelete.php b/src/app/Jobs/ProcessUserDelete.php index 2bf6d58f..5a0d10a5 100644 --- a/src/app/Jobs/ProcessUserDelete.php +++ b/src/app/Jobs/ProcessUserDelete.php @@ -1,36 +1,39 @@ user = $user; } /** * Execute the job. * * @return void */ public function handle() { // } } diff --git a/src/app/Jobs/ProcessUserRead.php b/src/app/Jobs/ProcessUserRead.php index 9df8d654..55d0d981 100644 --- a/src/app/Jobs/ProcessUserRead.php +++ b/src/app/Jobs/ProcessUserRead.php @@ -1,36 +1,39 @@ user = $user; } /** * Execute the job. * * @return void */ public function handle() { // } } diff --git a/src/app/Jobs/ProcessUserUpdate.php b/src/app/Jobs/ProcessUserUpdate.php index 9d044951..541f9f9b 100644 --- a/src/app/Jobs/ProcessUserUpdate.php +++ b/src/app/Jobs/ProcessUserUpdate.php @@ -1,36 +1,39 @@ user = $user; } /** * Execute the job. * * @return void */ public function handle() { // } } diff --git a/src/app/Jobs/SignupVerificationEmail.php b/src/app/Jobs/SignupVerificationEmail.php index 63e8c958..d9812eb0 100644 --- a/src/app/Jobs/SignupVerificationEmail.php +++ b/src/app/Jobs/SignupVerificationEmail.php @@ -1,58 +1,60 @@ code = $code; } /** * Determine the time at which the job should timeout. * * @return \DateTime */ public function retryUntil() { // FIXME: I think it does not make sense to continue trying after 1 hour return now()->addHours(1); } /** * Execute the job. * * @return void */ public function handle() { Mail::to($this->code->data['email'])->send(new SignupVerification($this->code)); } } diff --git a/src/app/Jobs/SignupVerificationSMS.php b/src/app/Jobs/SignupVerificationSMS.php index 6ebd794c..a4189faf 100644 --- a/src/app/Jobs/SignupVerificationSMS.php +++ b/src/app/Jobs/SignupVerificationSMS.php @@ -1,56 +1,58 @@ code = $code; } /** * Determine the time at which the job should timeout. * * @return \DateTime */ public function retryUntil() { // FIXME: I think it does not make sense to continue trying after 1 hour return now()->addHours(1); } /** * Execute the job. * * @return void */ public function handle() { // TODO } } diff --git a/src/app/Mail/PasswordReset.php b/src/app/Mail/PasswordReset.php new file mode 100644 index 00000000..97777b8d --- /dev/null +++ b/src/app/Mail/PasswordReset.php @@ -0,0 +1,57 @@ +code = $code; + } + + /** + * Build the message. + * + * @return $this + */ + public function build() + { + $href = sprintf( + '%s/login/reset/%s-%s', + \config('app.url'), + $this->code->short_code, + $this->code->code + ); + + $this->view('emails.password_reset') + ->subject(__('mail.passwordreset-subject', ['site' => \config('app.name')])) + ->with([ + 'site' => \config('app.name'), + 'code' => $this->code->code, + 'short_code' => $this->code->short_code, + 'link' => sprintf('%s', $href, $href), + 'username' => $this->code->user->name + ]); + + return $this; + } +} diff --git a/src/app/Mail/SignupVerification.php b/src/app/Mail/SignupVerification.php index 9fc88881..78481671 100644 --- a/src/app/Mail/SignupVerification.php +++ b/src/app/Mail/SignupVerification.php @@ -1,50 +1,57 @@ code = $code; } /** * Build the message. * * @return $this */ public function build() { + $href = sprintf( + '%s/signup/%s-%s', + \config('app.url'), + $this->code->short_code, + $this->code->code + ); + $this->view('emails.signup_code') ->subject(__('mail.signupcode-subject', ['site' => \config('app.name')])) ->with([ 'site' => \config('app.name'), 'username' => $this->code->data['name'], 'code' => $this->code->code, 'short_code' => $this->code->short_code, - 'url_code' => $this->code->short_code . '-' . $this->code->code, + 'link' => sprintf('%s', $href, $href), ]); return $this; } } diff --git a/src/app/Observers/SignupCodeObserver.php b/src/app/Observers/SignupCodeObserver.php index 7222059b..45463df9 100644 --- a/src/app/Observers/SignupCodeObserver.php +++ b/src/app/Observers/SignupCodeObserver.php @@ -1,38 +1,38 @@ code)) { $code->short_code = SignupCode::generateShortCode(); // FIXME: Replace this with something race-condition free while (true) { $code->code = str_random($code_length); if (!SignupCode::find($code->code)) { break; } } } $code->expires_at = Carbon::now()->addHours($exp_hours); } } diff --git a/src/app/Observers/SignupCodeObserver.php b/src/app/Observers/VerificationCodeObserver.php similarity index 56% copy from src/app/Observers/SignupCodeObserver.php copy to src/app/Observers/VerificationCodeObserver.php index 7222059b..9b41bc66 100644 --- a/src/app/Observers/SignupCodeObserver.php +++ b/src/app/Observers/VerificationCodeObserver.php @@ -1,38 +1,39 @@ code)) { - $code->short_code = SignupCode::generateShortCode(); + $code->short_code = VerificationCode::generateShortCode(); // FIXME: Replace this with something race-condition free while (true) { $code->code = str_random($code_length); - if (!SignupCode::find($code->code)) { + if (!VerificationCode::find($code->code)) { break; } } } $code->expires_at = Carbon::now()->addHours($exp_hours); } } diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php index d949d51f..3934520f 100644 --- a/src/app/Providers/AppServiceProvider.php +++ b/src/app/Providers/AppServiceProvider.php @@ -1,42 +1,43 @@ sql, implode(', ', $query->bindings))); }); } } } diff --git a/src/app/SignupCode.php b/src/app/SignupCode.php index 59b56ec5..95417f1b 100644 --- a/src/app/SignupCode.php +++ b/src/app/SignupCode.php @@ -1,100 +1,100 @@ 'array']; /** * The attributes that should be mutated to dates. * * @var array */ protected $dates = ['expires_at']; /** * Check if code is expired. * * @return bool True if code is expired, False otherwise */ public function isExpired() { return $this->expires_at ? Carbon::now()->gte($this->expires_at) : false; } /** * Generate a short code (for human). * * @return string */ public static function generateShortCode(): string { $code_length = env('SIGNUP_CODE_LENGTH', self::SHORTCODE_LENGTH); $code_chars = env('SIGNUP_CODE_CHARS', self::SHORTCODE_CHARS); $random = []; for ($i = 1; $i <= $code_length; $i++) { $random[] = $code_chars[rand(0, strlen($code_chars) - 1)]; } shuffle($random); return implode('', $random); } } diff --git a/src/app/Traits/UserSettingsTrait.php b/src/app/Traits/UserSettingsTrait.php index 842050f8..f9430d65 100644 --- a/src/app/Traits/UserSettingsTrait.php +++ b/src/app/Traits/UserSettingsTrait.php @@ -1,110 +1,113 @@ 'some@other.erg']); * $locale = $user->getSetting('locale'); * ``` * * @param string $key Lookup key * * @return string */ - public function getSetting($key) + public function getSetting(string $key) { $settings = $this->getCache(); $value = array_get($settings, $key); return ($value !== '') ? $value : null; } /** * Create or update a setting. * * Example Usage: * * ```php * $user = User::firstOrCreate(['email' => 'some@other.erg']); * $user->setSetting('locale', 'en'); * ``` * * @param string $key Setting name * @param string $value The new value for the setting. * * @return void */ - public function setSetting($key, $value) + public function setSetting(string $key, $value) { $this->storeSetting($key, $value); $this->setCache(); } /** * Create or update multiple settings in one fell swoop. * * Example Usage: * * ```php * $user = User::firstOrCreate(['email' => 'some@other.erg']); * $user->setSettings(['locale', 'en', 'country' => 'GB']); * ``` * * @param array $data An associative array of key value pairs. * * @return void */ - public function setSettings($data = []) + public function setSettings(array $data = []) { foreach ($data as $key => $value) { $this->storeSetting($key, $value); } $this->setCache(); } - private function storeSetting($key, $value) + private function storeSetting(string $key, $value) { $record = UserSetting::where(['user_id' => $this->id, 'key' => $key])->first(); if ($record) { $record->value = $value; $record->save(); } else { $data = new UserSetting(['key' => $key, 'value' => $value]); $this->settings()->save($data); } } private function getCache() { if (Cache::has('user_settings_' . $this->id)) { return Cache::get('user_settings_' . $this->id); } return $this->setCache(); } private function setCache() { if (Cache::has('user_settings_' . $this->id)) { Cache::forget('user_settings_' . $this->id); } - $settings = $this->settings()->get(); + $cached = []; + foreach ($this->settings()->get() as $entry) { + $cached[$entry->key] = $entry->value; + } - Cache::forever('user_settings_' . $this->id, $settings); + Cache::forever('user_settings_' . $this->id, $cached); return $this->getCache(); } } diff --git a/src/app/User.php b/src/app/User.php index 010c8b38..4a2d8bcc 100644 --- a/src/app/User.php +++ b/src/app/User.php @@ -1,190 +1,206 @@ 'datetime', ]; /** * Any wallets on which this user is a controller. * * @return Wallet[] */ 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 ); } /** * List the domains to which this user is entitled. * * @return Domain[] */ public function domains() { $domains = Domain::whereRaw( sprintf( '(type & %s) AND (status & %s)', Domain::TYPE_PUBLIC, Domain::STATUS_ACTIVE ) )->get(); foreach ($this->entitlements()->get() as $entitlement) { if ($entitlement->entitleable instanceof Domain) { $domain = Domain::find($entitlement->entitleable_id); \Log::info("Found domain {$domain->namespace}"); $domains[] = $domain; } } foreach ($this->accounts()->get() as $wallet) { foreach ($wallet->entitlements()->get() as $entitlement) { if ($entitlement->entitleable instanceof Domain) { $domain = Domain::find($entitlement->entitleable_id); \Log::info("Found domain {$domain->namespace}"); $domains[] = $domain; } } } return $domains; } public function entitlement() { return $this->morphOne('App\Entitlement', 'entitleable'); } /** * Entitlements for this user. * * @return Entitlement[] */ public function entitlements() { return $this->hasMany('App\Entitlement', 'owner_id', 'id'); } public function addEntitlement($entitlement) { if (!$this->entitlements()->get()->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 + */ + public static function findByEmail(string $email) + { + if (strpos($email, '@') === false) { + return; + } + + $user = self::where('email', $email); + + return $user->count() === 1 ? $user->first() : null; + + // TODO: Aliases, External email + } + public function settings() { return $this->hasMany('App\UserSetting', 'user_id'); } /** - * Return single user setting value - * - * @param string $key Setting key name + * Verification codes for this user. * - * @return string Setting value + * @return VerificationCode[] */ - public function getSetting(string $key, $default = null) + public function verificationcodes() { - $setting = $this->settings->where('key', $key)->first(); - - return $setting ? $setting->value : $default; + return $this->hasMany('App\VerificationCode', 'user_id', 'id'); } /** * Wallets this user owns. * * @return Wallet[] */ public function wallets() { return $this->hasMany('App\Wallet'); } public function getJWTIdentifier() { return $this->getKey(); } public function getJWTCustomClaims() { return []; } 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)) ); } } 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)) ); } } } diff --git a/src/app/UserSetting.php b/src/app/UserSetting.php index 3cf0ad0d..e2908972 100644 --- a/src/app/UserSetting.php +++ b/src/app/UserSetting.php @@ -1,30 +1,30 @@ belongsTo('\App\User', 'id', 'user_id'); + return $this->belongsTo('\App\User', 'user_id', 'id'); } } diff --git a/src/app/VerificationCode.php b/src/app/VerificationCode.php new file mode 100644 index 00000000..80e4b7c7 --- /dev/null +++ b/src/app/VerificationCode.php @@ -0,0 +1,62 @@ +belongsTo('\App\User', 'user_id', 'id'); + } + + /** + * Generate a short code (for human). + * + * @return string + */ + public static function generateShortCode(): string + { + $code_length = env('VERIFICATION_CODE_LENGTH', self::SHORTCODE_LENGTH); + $code_chars = env('VERIFICATION_CODE_CHARS', self::SHORTCODE_CHARS); + $random = []; + + for ($i = 1; $i <= $code_length; $i++) { + $random[] = $code_chars[rand(0, strlen($code_chars) - 1)]; + } + + shuffle($random); + + return implode('', $random); + } +} diff --git a/src/database/migrations/2019_12_20_130000_create_verification_codes_table.php b/src/database/migrations/2019_12_20_130000_create_verification_codes_table.php new file mode 100644 index 00000000..e8daf8c6 --- /dev/null +++ b/src/database/migrations/2019_12_20_130000_create_verification_codes_table.php @@ -0,0 +1,37 @@ +bigInteger('user_id'); + $table->string('code', 32); + $table->string('short_code', 16); + $table->string('mode'); + $table->timestamp('expires_at'); + + $table->primary('code'); + + $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::drop('verification_codes'); + } +} diff --git a/src/resources/lang/en/mail.php b/src/resources/lang/en/mail.php index d1f08499..5b5b3f97 100644 --- a/src/resources/lang/en/mail.php +++ b/src/resources/lang/en/mail.php @@ -1,19 +1,25 @@ "Dear :name,", 'footer' => "Best regards,\nYour :site Team", + 'passwordreset-subject' => ":site Password Reset", + 'passwordreset-body' => "Someone recently asked to change your :site password.\n" + . "If this was you, use this verification code to complete the process: :code.\n" + . "You can also click the link below.\n" + . "If you did not make such a request, you can either ignore this message or get in touch with us about this incident.", + 'signupcode-subject' => ":site Registration", 'signupcode-body' => "This is your verification code for the :site registration process: :code.\n" . "You can also click the link below to continue the registration process:", ]; diff --git a/src/resources/lang/en/validation.php b/src/resources/lang/en/validation.php index 57695f52..23261b79 100644 --- a/src/resources/lang/en/validation.php +++ b/src/resources/lang/en/validation.php @@ -1,154 +1,156 @@ 'The :attribute must be accepted.', 'active_url' => 'The :attribute is not a valid URL.', 'after' => 'The :attribute must be a date after :date.', 'after_or_equal' => 'The :attribute must be a date after or equal to :date.', 'alpha' => 'The :attribute may only contain letters.', 'alpha_dash' => 'The :attribute may only contain letters, numbers, dashes and underscores.', 'alpha_num' => 'The :attribute may only contain letters and numbers.', 'array' => 'The :attribute must be an array.', 'before' => 'The :attribute must be a date before :date.', 'before_or_equal' => 'The :attribute must be a date before or equal to :date.', 'between' => [ 'numeric' => 'The :attribute must be between :min and :max.', 'file' => 'The :attribute must be between :min and :max kilobytes.', 'string' => 'The :attribute must be between :min and :max characters.', 'array' => 'The :attribute must have between :min and :max items.', ], 'boolean' => 'The :attribute field must be true or false.', 'confirmed' => 'The :attribute confirmation does not match.', 'date' => 'The :attribute is not a valid date.', 'date_equals' => 'The :attribute must be a date equal to :date.', 'date_format' => 'The :attribute does not match the format :format.', 'different' => 'The :attribute and :other must be different.', 'digits' => 'The :attribute must be :digits digits.', 'digits_between' => 'The :attribute must be between :min and :max digits.', 'dimensions' => 'The :attribute has invalid image dimensions.', 'distinct' => 'The :attribute field has a duplicate value.', 'email' => 'The :attribute must be a valid email address.', 'ends_with' => 'The :attribute must end with one of the following: :values', 'exists' => 'The selected :attribute is invalid.', 'file' => 'The :attribute must be a file.', 'filled' => 'The :attribute field must have a value.', 'gt' => [ 'numeric' => 'The :attribute must be greater than :value.', 'file' => 'The :attribute must be greater than :value kilobytes.', 'string' => 'The :attribute must be greater than :value characters.', 'array' => 'The :attribute must have more than :value items.', ], 'gte' => [ 'numeric' => 'The :attribute must be greater than or equal :value.', 'file' => 'The :attribute must be greater than or equal :value kilobytes.', 'string' => 'The :attribute must be greater than or equal :value characters.', 'array' => 'The :attribute must have :value items or more.', ], 'image' => 'The :attribute must be an image.', 'in' => 'The selected :attribute is invalid.', 'in_array' => 'The :attribute field does not exist in :other.', 'integer' => 'The :attribute must be an integer.', 'ip' => 'The :attribute must be a valid IP address.', 'ipv4' => 'The :attribute must be a valid IPv4 address.', 'ipv6' => 'The :attribute must be a valid IPv6 address.', 'json' => 'The :attribute must be a valid JSON string.', 'lt' => [ 'numeric' => 'The :attribute must be less than :value.', 'file' => 'The :attribute must be less than :value kilobytes.', 'string' => 'The :attribute must be less than :value characters.', 'array' => 'The :attribute must have less than :value items.', ], 'lte' => [ 'numeric' => 'The :attribute must be less than or equal :value.', 'file' => 'The :attribute must be less than or equal :value kilobytes.', 'string' => 'The :attribute must be less than or equal :value characters.', 'array' => 'The :attribute must not have more than :value items.', ], 'max' => [ 'numeric' => 'The :attribute may not be greater than :max.', 'file' => 'The :attribute may not be greater than :max kilobytes.', 'string' => 'The :attribute may not be greater than :max characters.', 'array' => 'The :attribute may not have more than :max items.', ], 'mimes' => 'The :attribute must be a file of type: :values.', 'mimetypes' => 'The :attribute must be a file of type: :values.', 'min' => [ 'numeric' => 'The :attribute must be at least :min.', 'file' => 'The :attribute must be at least :min kilobytes.', 'string' => 'The :attribute must be at least :min characters.', 'array' => 'The :attribute must have at least :min items.', ], 'not_in' => 'The selected :attribute is invalid.', 'not_regex' => 'The :attribute format is invalid.', 'numeric' => 'The :attribute must be a number.', 'present' => 'The :attribute field must be present.', 'regex' => 'The :attribute format is invalid.', 'required' => 'The :attribute field is required.', 'required_if' => 'The :attribute field is required when :other is :value.', 'required_unless' => 'The :attribute field is required unless :other is in :values.', 'required_with' => 'The :attribute field is required when :values is present.', 'required_with_all' => 'The :attribute field is required when :values are present.', 'required_without' => 'The :attribute field is required when :values is not present.', 'required_without_all' => 'The :attribute field is required when none of :values are present.', 'same' => 'The :attribute and :other must match.', 'size' => [ 'numeric' => 'The :attribute must be :size.', 'file' => 'The :attribute must be :size kilobytes.', 'string' => 'The :attribute must be :size characters.', 'array' => 'The :attribute must contain :size items.', ], 'starts_with' => 'The :attribute must start with one of the following: :values', 'string' => 'The :attribute must be a string.', 'timezone' => 'The :attribute must be a valid zone.', 'unique' => 'The :attribute has already been taken.', 'uploaded' => 'The :attribute failed to upload.', 'url' => 'The :attribute format is invalid.', 'uuid' => 'The :attribute must be a valid UUID.', 'emailexists' => 'The specified email address already exists', 'emailinvalid' => 'The specified email address is invalid', 'noemailorphone' => 'The specified text is neither a valid email address nor a phone number', + 'usernotexists' => 'Unable to find user', + 'noextemail' => 'This user has no external email address', /* |-------------------------------------------------------------------------- | Custom Validation Language Lines |-------------------------------------------------------------------------- | | Here you may specify custom validation messages for attributes using the | convention "attribute.rule" to name the lines. This makes it quick to | specify a specific custom language line for a given attribute rule. | */ 'custom' => [ 'attribute-name' => [ 'rule-name' => 'custom-message', ], ], /* |-------------------------------------------------------------------------- | Custom Validation Attributes |-------------------------------------------------------------------------- | | The following language lines are used to swap our attribute placeholder | with something more reader friendly such as "E-Mail Address" instead | of "email". This simply helps us make our message more expressive. | */ 'attributes' => [], ]; diff --git a/src/resources/views/emails/signup_code.blade.php b/src/resources/views/emails/password_reset.blade.php similarity index 59% copy from src/resources/views/emails/signup_code.blade.php copy to src/resources/views/emails/password_reset.blade.php index d5f41c63..85751758 100644 --- a/src/resources/views/emails/signup_code.blade.php +++ b/src/resources/views/emails/password_reset.blade.php @@ -1,15 +1,15 @@

{{ __('mail.header', ['name' => $username]) }}

-

{{ __('mail.signupcode-body', ['code' => $short_code, 'site' => $site]) }}

+

{{ __('mail.passwordreset-body', ['code' => $short_code, 'site' => $site]) }}

-

{{ config('app.url') }}/signup/{{ $url_code }}

+

{!! $link !!}

{{ __('mail.footer', ['site' => $site, 'appurl' => config('app.url')]) }}

diff --git a/src/resources/views/emails/signup_code.blade.php b/src/resources/views/emails/signup_code.blade.php index d5f41c63..e14af11a 100644 --- a/src/resources/views/emails/signup_code.blade.php +++ b/src/resources/views/emails/signup_code.blade.php @@ -1,15 +1,15 @@

{{ __('mail.header', ['name' => $username]) }}

{{ __('mail.signupcode-body', ['code' => $short_code, 'site' => $site]) }}

-

{{ config('app.url') }}/signup/{{ $url_code }}

+

{!! $link !!}

{{ __('mail.footer', ['site' => $site, 'appurl' => config('app.url')]) }}

diff --git a/src/resources/vue/components/Login.vue b/src/resources/vue/components/Login.vue index 6427c23f..358dd24e 100644 --- a/src/resources/vue/components/Login.vue +++ b/src/resources/vue/components/Login.vue @@ -1,79 +1,81 @@ diff --git a/src/resources/vue/components/Signup.vue b/src/resources/vue/components/PasswordReset.vue similarity index 65% copy from src/resources/vue/components/Signup.vue copy to src/resources/vue/components/PasswordReset.vue index 5be756f5..156c079e 100644 --- a/src/resources/vue/components/Signup.vue +++ b/src/resources/vue/components/PasswordReset.vue @@ -1,184 +1,161 @@ diff --git a/src/resources/vue/components/Signup.vue b/src/resources/vue/components/Signup.vue index 5be756f5..1aba7e8c 100644 --- a/src/resources/vue/components/Signup.vue +++ b/src/resources/vue/components/Signup.vue @@ -1,184 +1,183 @@ diff --git a/src/resources/vue/js/routes.js b/src/resources/vue/js/routes.js index 2e4373a0..edbd1805 100644 --- a/src/resources/vue/js/routes.js +++ b/src/resources/vue/js/routes.js @@ -1,70 +1,76 @@ import Vue from 'vue' import VueRouter from 'vue-router' Vue.use(VueRouter) import DashboardComponent from '../components/Dashboard' 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 store from './store' const routes = [ { path: '/', redirect: { name: 'login' } }, { path: '/dashboard', name: 'dashboard', component: DashboardComponent, 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: '/signup/:code?', name: 'signup', component: SignupComponent }, { 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) { // redirect to login page next({ name: 'login' }) return } // if logged in redirect to dashboard if (to.path === '/login' && store.state.isLoggedIn) { next({ name: 'dashboard' }) return } next() }) export default router diff --git a/src/routes/api.php b/src/routes/api.php index 76d8cef6..c4906bb9 100644 --- a/src/routes/api.php +++ b/src/routes/api.php @@ -1,43 +1,47 @@ 'api', 'prefix' => 'auth' ], function ($router) { Route::get('info', 'API\UsersController@info'); Route::post('login', 'API\UsersController@login'); Route::post('logout', 'API\UsersController@logout'); Route::post('refresh', 'API\UsersController@refresh'); + Route::post('password-reset/init', 'API\PasswordResetController@init'); + Route::post('password-reset/verify', 'API\PasswordResetController@verify'); + Route::post('password-reset', 'API\PasswordResetController@reset'); + Route::post('signup/init', 'API\SignupController@init'); Route::post('signup/verify', 'API\SignupController@verify'); Route::post('signup', 'API\SignupController@signup'); } ); Route::group( [ 'middleware' => 'auth:api', 'prefix' => 'v4' ], function () { Route::apiResource('entitlements', API\EntitlementsController::class); Route::apiResource('users', API\UsersController::class); Route::apiResource('wallets', API\WalletsController::class); } ); diff --git a/src/tests/Browser/LogonTest.php b/src/tests/Browser/LogonTest.php index 78be7c57..c21601ef 100644 --- a/src/tests/Browser/LogonTest.php +++ b/src/tests/Browser/LogonTest.php @@ -1,64 +1,63 @@ browse(function (Browser $browser) { $browser->visit('/dashboard'); // Checks if we're really on the login page $browser->waitForLocation('/login') ->on(new Home()); }); } /** * Logon with wrong password/user test * * @return void */ public function testLogonWrongCredentials() { $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('john@kolab.org', 'wrong'); // Checks if we're still on the logon page // FIXME: This assertion might be prone to timing issues // I guess we should wait until some error message appears $browser->on(new Home()); }); } /** * Successful logon test * * @return void */ public function testLogonSuccessful() { $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('john@kolab.org', 'simple123', true); // Checks if we're really on Dashboard page $browser->on(new Dashboard()); }); } } diff --git a/src/tests/Browser/Pages/PasswordReset.php b/src/tests/Browser/Pages/PasswordReset.php new file mode 100644 index 00000000..1b9429e7 --- /dev/null +++ b/src/tests/Browser/Pages/PasswordReset.php @@ -0,0 +1,47 @@ +assertPathIs('/password-reset'); + $browser->assertPresent('@step1'); + } + + /** + * Get the element shortcuts for the page. + * + * @return array + */ + public function elements(): array + { + return [ + '@app' => '#app', + '@step1' => '#step1', + '@step2' => '#step2', + '@step3' => '#step3', + ]; + } +} diff --git a/src/tests/Browser/PasswordResetTest.php b/src/tests/Browser/PasswordResetTest.php new file mode 100644 index 00000000..753daad8 --- /dev/null +++ b/src/tests/Browser/PasswordResetTest.php @@ -0,0 +1,258 @@ + 'passwordresettestdusk@' . \config('app.domain')]); + $user->setSetting('external_email', 'external@domain.tld'); + } + + /** + * {@inheritDoc} + * + * @return void + */ + public function tearDown(): void + { + User::where('email', 'passwordresettestdusk@' . \config('app.domain'))->delete(); + + parent::tearDown(); + } + + /** + * Test 1st step of password-reset + * + * @return void + */ + public function testPasswordResetStep1() + { + $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 + * @return void + */ + public function testPasswordResetStep2() + { + $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 + * @return void + */ + public function testPasswordResetStep3() + { + $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); + + // 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 5fe5382d..9521692d 100644 --- a/src/tests/Browser/SignupTest.php +++ b/src/tests/Browser/SignupTest.php @@ -1,320 +1,313 @@ delete(); parent::tearDown(); } /** * Test signup code verification with a link * * @return void */ public function testSignupCodeByLink() { // 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'); $browser->waitFor('.toast-error'); $browser->click('.toast-error'); // remove the toast }); // Test valid code $this->browse(function (Browser $browser) { $code = SignupCode::create([ 'data' => [ 'email' => 'User@example.org', 'name' => 'User Name', ] ]); $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 1st step of the signup process * * @return void */ public function testSignupStep1() { $this->browse(function (Browser $browser) { $browser->visit(new Signup()); $browser->assertVisible('@step1'); // Here we expect two text inputs and Continue $browser->with('@step1', function ($step) { $step->assertVisible('#signup_name'); $step->assertFocused('#signup_name'); $step->assertVisible('#signup_email'); $step->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'); $step->type('#signup_email', '@test'); $step->click('[type=submit]'); $step->waitFor('#signup_email.is-invalid'); $step->waitFor('#signup_email + .invalid-feedback'); $browser->waitFor('.toast-error'); $browser->click('.toast-error'); // remove the toast }); // 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'); - - $step->waitUntilMissing('#signup_code[value=""]'); }); + $browser->waitUntilMissing('@step2 #signup_code[value=""]'); $browser->waitFor('@step2'); $browser->assertMissing('@step1'); }); } /** * Test 2nd Step of the signup process * * @depends testSignupStep1 * @return void */ public function testSignupStep2() { $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('#signup_short_code'); $step->assertFocused('#signup_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 #signup_name'); $browser->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]'); $browser->waitFor('.toast-error'); $step->assertVisible('#signup_short_code.is-invalid'); $step->assertVisible('#signup_short_code + .invalid-feedback'); $step->assertFocused('#signup_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('#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 * @return void */ public function testSignupStep3() { $this->browse(function (Browser $browser) { $browser->assertVisible('@step3'); - // Here we expect one text input, Back and Continue buttons + // 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('[type=button]'); $step->assertVisible('[type=submit]'); $step->assertFocused('#signup_login'); $step->assertSeeIn('#signup_login + span', '@' . \config('app.domain')); }); // 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'); $step->type('#signup_login', '*'); $step->type('#signup_password', '12345678'); $step->type('#signup_confirm', '123456789'); $step->click('[type=submit]'); $browser->waitFor('.toast-error'); $step->assertVisible('#signup_login.is-invalid'); $step->assertVisible('#signup_login + span + .invalid-feedback'); $step->assertVisible('#signup_password.is-invalid'); $step->assertVisible('#signup_password + .invalid-feedback'); $step->assertFocused('#signup_login'); $browser->click('.toast-error'); // remove the toast }); // Submit invalid data (valid login, invalid password) $browser->with('@step3', function ($step) use ($browser) { - // FIXME: For some reason I can't just use ->value() here - $step->clear('#signup_login'); $step->type('#signup_login', 'SignupTestDusk'); $step->click('[type=submit]'); $browser->waitFor('.toast-error'); $step->assertVisible('#signup_password.is-invalid'); $step->assertVisible('#signup_password + .invalid-feedback'); $step->assertMissing('#signup_login.is-invalid'); $step->assertMissing('#signup_login + span + .invalid-feedback'); $step->assertFocused('#signup_password'); $browser->click('.toast-error'); // remove the toast }); // Submit valid data $browser->with('@step3', function ($step) { - // FIXME: For some reason I can't just use ->value() here - $step->clear('#signup_confirm'); $step->type('#signup_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); // FIXME: Is it enough to be sure user is logged in? }); } } diff --git a/src/tests/Feature/Controller/PasswordResetTest.php b/src/tests/Feature/Controller/PasswordResetTest.php new file mode 100644 index 00000000..69f2f5e9 --- /dev/null +++ b/src/tests/Feature/Controller/PasswordResetTest.php @@ -0,0 +1,328 @@ + 'passwordresettest@' . \config('app.domain')]); + } + + /** + * {@inheritDoc} + * + * @return void + */ + public function tearDown(): void + { + User::where('email', 'passwordresettest@' . \config('app.domain')) + ->delete(); + + parent::tearDown(); + } + + /** + * Test password-reset/init with invalid input + * + * @return void + */ + public function testPasswordResetInitInvalidInput() + { + // Empty input data + $data = []; + + $response = $this->post('/api/auth/password-reset/init', $data); + $json = $response->json(); + + $response->assertStatus(422); + + $this->assertSame('error', $json['status']); + $this->assertCount(1, $json['errors']); + $this->assertArrayHasKey('email', $json['errors']); + + // Data with invalid email + $data = [ + 'email' => '@example.org', + ]; + + $response = $this->post('/api/auth/password-reset/init', $data); + $json = $response->json(); + + $response->assertStatus(422); + + $this->assertSame('error', $json['status']); + $this->assertCount(1, $json['errors']); + $this->assertArrayHasKey('email', $json['errors']); + + // Data with valid but non-existing email + $data = [ + 'email' => 'non-existing-password-reset@example.org', + ]; + + $response = $this->post('/api/auth/password-reset/init', $data); + $json = $response->json(); + + $response->assertStatus(422); + + $this->assertSame('error', $json['status']); + $this->assertCount(1, $json['errors']); + $this->assertArrayHasKey('email', $json['errors']); + + // Data with valid email af an existing user with no external email + $data = [ + 'email' => 'passwordresettest@' . \config('app.domain'), + ]; + + $response = $this->post('/api/auth/password-reset/init', $data); + $json = $response->json(); + + $response->assertStatus(422); + + $this->assertSame('error', $json['status']); + $this->assertCount(1, $json['errors']); + $this->assertArrayHasKey('email', $json['errors']); + } + + /** + * Test password-reset/init with valid input + * + * @return array + */ + public function testPasswordResetInitValidInput() + { + Queue::fake(); + + // Assert that no jobs were pushed... + Queue::assertNothingPushed(); + + // Add required external email address to user settings + $user = User::where('email', 'passwordresettest@' . \config('app.domain'))->first(); + $user->setSetting('external_email', 'ext@email.com'); + + $data = [ + 'email' => 'passwordresettest@' . \config('app.domain'), + ]; + + $response = $this->post('/api/auth/password-reset/init', $data); + $json = $response->json(); + + $response->assertStatus(200); + $this->assertCount(2, $json); + $this->assertSame('success', $json['status']); + $this->assertNotEmpty($json['code']); + + // Assert the email sending job was pushed once + Queue::assertPushed(\App\Jobs\PasswordResetEmail::class, 1); + + // Assert the job has proper data assigned + Queue::assertPushed(\App\Jobs\PasswordResetEmail::class, function ($job) use ($user, &$code, $json) { + // Access protected property + $reflection = new \ReflectionClass($job); + $code = $reflection->getProperty('code'); + $code->setAccessible(true); + $code = $code->getValue($job); + + return $code->user->id === $user->id && $code->code == $json['code']; + }); + + return [ + 'code' => $code + ]; + } + + /** + * Test password-reset/verify with invalid input + * + * @return void + */ + public function testPasswordResetVerifyInvalidInput() + { + // Empty data + $data = []; + + $response = $this->post('/api/auth/password-reset/verify', $data); + $json = $response->json(); + + $response->assertStatus(422); + $this->assertSame('error', $json['status']); + $this->assertCount(2, $json['errors']); + $this->assertArrayHasKey('short_code', $json['errors']); + + // Add verification code and required external email address to user settings + $user = User::where('email', 'passwordresettest@' . \config('app.domain'))->first(); + $code = new VerificationCode(['mode' => 'password-reset']); + $user->verificationcodes()->save($code); + + // Data with existing code but missing short_code + $data = [ + 'code' => $code->code, + ]; + + $response = $this->post('/api/auth/password-reset/verify', $data); + $json = $response->json(); + + $response->assertStatus(422); + $this->assertSame('error', $json['status']); + $this->assertCount(1, $json['errors']); + $this->assertArrayHasKey('short_code', $json['errors']); + + // Data with invalid code + $data = [ + 'short_code' => '123456789', + 'code' => $code->code, + ]; + + $response = $this->post('/api/auth/password-reset/verify', $data); + $json = $response->json(); + + $response->assertStatus(422); + $this->assertSame('error', $json['status']); + $this->assertCount(1, $json['errors']); + $this->assertArrayHasKey('short_code', $json['errors']); + + // TODO: Test expired code + } + + /** + * Test password-reset/verify with valid input + * + * @return void + */ + public function testPasswordResetVerifyValidInput() + { + // Add verification code and required external email address to user settings + $user = User::where('email', 'passwordresettest@' . \config('app.domain'))->first(); + $code = new VerificationCode(['mode' => 'password-reset']); + $user->verificationcodes()->save($code); + + // Data with invalid code + $data = [ + 'short_code' => $code->short_code, + 'code' => $code->code, + ]; + + $response = $this->post('/api/auth/password-reset/verify', $data); + $json = $response->json(); + + $response->assertStatus(200); + $this->assertCount(1, $json); + $this->assertSame('success', $json['status']); + } + + /** + * Test password-reset with invalid input + * + * @return void + */ + public function testPasswordResetInvalidInput() + { + // Empty data + $data = []; + + $response = $this->post('/api/auth/password-reset', $data); + $json = $response->json(); + + $response->assertStatus(422); + $this->assertSame('error', $json['status']); + $this->assertCount(1, $json['errors']); + $this->assertArrayHasKey('password', $json['errors']); + + $user = User::where('email', 'passwordresettest@' . \config('app.domain'))->first(); + $code = new VerificationCode(['mode' => 'password-reset']); + $user->verificationcodes()->save($code); + + // Data with existing code but missing password + $data = [ + 'code' => $code->code, + ]; + + $response = $this->post('/api/auth/password-reset', $data); + $json = $response->json(); + + $response->assertStatus(422); + $this->assertSame('error', $json['status']); + $this->assertCount(1, $json['errors']); + $this->assertArrayHasKey('password', $json['errors']); + + // Data with existing code but wrong password confirmation + $data = [ + 'code' => $code->code, + 'short_code' => $code->short_code, + 'password' => 'password', + 'password_confirmation' => 'passwrong', + ]; + + $response = $this->post('/api/auth/password-reset', $data); + $json = $response->json(); + + $response->assertStatus(422); + $this->assertSame('error', $json['status']); + $this->assertCount(1, $json['errors']); + $this->assertArrayHasKey('password', $json['errors']); + + // Data with invalid short code + $data = [ + 'code' => $code->code, + 'short_code' => '123456789', + 'password' => 'password', + 'password_confirmation' => 'password', + ]; + + $response = $this->post('/api/auth/password-reset', $data); + $json = $response->json(); + + $response->assertStatus(422); + $this->assertSame('error', $json['status']); + $this->assertCount(1, $json['errors']); + $this->assertArrayHasKey('short_code', $json['errors']); + } + + /** + * Test password reset with valid input + * + * @return void + */ + public function testPasswordResetValidInput() + { + $user = User::where('email', 'passwordresettest@' . \config('app.domain'))->first(); + $code = new VerificationCode(['mode' => 'password-reset']); + $user->verificationcodes()->save($code); + + $data = [ + 'password' => 'test', + 'password_confirmation' => 'test', + 'code' => $code->code, + 'short_code' => $code->short_code, + ]; + + $response = $this->post('/api/auth/password-reset', $data); + $json = $response->json(); + + $response->assertStatus(200); + $this->assertCount(4, $json); + $this->assertSame('success', $json['status']); + $this->assertSame('bearer', $json['token_type']); + $this->assertTrue(!empty($json['expires_in']) && is_int($json['expires_in']) && $json['expires_in'] > 0); + $this->assertNotEmpty($json['access_token']); + + // Check if the code has been removed + $this->assertNull(VerificationCode::find($code->code)); + + // TODO: Check password before and after (?) + + // TODO: Check if the access token works + } +} diff --git a/src/tests/Feature/Controller/SignupTest.php b/src/tests/Feature/Controller/SignupTest.php index eddd25df..4249d9e2 100644 --- a/src/tests/Feature/Controller/SignupTest.php +++ b/src/tests/Feature/Controller/SignupTest.php @@ -1,405 +1,404 @@ 'SignupControllerTest1@' . \config('app.domain')]); } /** * {@inheritDoc} * * @return void */ public function tearDown(): void { User::where('email', 'signuplogin@' . \config('app.domain')) ->orWhere('email', 'SignupControllerTest1@' . \config('app.domain')) ->delete(); parent::tearDown(); } /** * Test signup initialization with invalid input * * @return void */ public function testSignupInitInvalidInput() { // Empty input data $data = []; $response = $this->post('/api/auth/signup/init', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertArrayHasKey('email', $json['errors']); $this->assertArrayHasKey('name', $json['errors']); // Data with missing name $data = [ 'email' => 'UsersApiControllerTest1@UsersApiControllerTest.com', 'password' => 'simple123', 'password_confirmation' => 'simple123' ]; $response = $this->post('/api/auth/signup/init', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('name', $json['errors']); // Data with invalid email (but not phone number) $data = [ 'email' => '@example.org', 'name' => 'Signup User', 'password' => 'simple123', 'password_confirmation' => 'simple123' ]; $response = $this->post('/api/auth/signup/init', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('email', $json['errors']); // TODO: Test phone validation } /** * Test signup initialization with valid input * * @return array */ public function testSignupInitValidInput() { Queue::fake(); // Assert that no jobs were pushed... Queue::assertNothingPushed(); $data = [ 'email' => 'testuser@external.com', 'name' => 'Signup User', 'password' => 'simple123', 'password_confirmation' => 'simple123' ]; $response = $this->post('/api/auth/signup/init', $data); $json = $response->json(); $response->assertStatus(200); $this->assertCount(2, $json); $this->assertSame('success', $json['status']); $this->assertNotEmpty($json['code']); // Assert the email sending job was pushed once Queue::assertPushed(\App\Jobs\SignupVerificationEmail::class, 1); // Assert the job has proper data assigned Queue::assertPushed(\App\Jobs\SignupVerificationEmail::class, function ($job) use ($data, $json) { // Access protected property $reflection = new \ReflectionClass($job); $code = $reflection->getProperty('code'); $code->setAccessible(true); $code = $code->getValue($job); return $code->code === $json['code'] && $code->data['email'] === $data['email'] && $code->data['name'] === $data['name']; }); return [ 'code' => $json['code'], 'email' => $data['email'], 'name' => $data['name'], ]; } /** * Test signup code verification with invalid input * * @depends testSignupInitValidInput * @return void */ public function testSignupVerifyInvalidInput(array $result) { // Empty data $data = []; $response = $this->post('/api/auth/signup/verify', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertArrayHasKey('code', $json['errors']); $this->assertArrayHasKey('short_code', $json['errors']); // Data with existing code but missing short_code $data = [ 'code' => $result['code'], ]; $response = $this->post('/api/auth/signup/verify', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('short_code', $json['errors']); // Data with invalid short_code $data = [ 'code' => $result['code'], 'short_code' => 'XXXX', ]; $response = $this->post('/api/auth/signup/verify', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('short_code', $json['errors']); // TODO: Test expired code } /** * Test signup code verification with valid input * * @depends testSignupInitValidInput * * @return array */ public function testSignupVerifyValidInput(array $result) { $code = SignupCode::find($result['code']); $data = [ 'code' => $code->code, 'short_code' => $code->short_code, ]; $response = $this->post('/api/auth/signup/verify', $data); $json = $response->json(); $response->assertStatus(200); $this->assertCount(3, $json); $this->assertSame('success', $json['status']); $this->assertSame($result['email'], $json['email']); $this->assertSame($result['name'], $json['name']); return $result; } /** * Test last signup step with invalid input * * @depends testSignupVerifyValidInput * @return void */ public function testSignupInvalidInput(array $result) { // Empty data $data = []; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertArrayHasKey('login', $json['errors']); $this->assertArrayHasKey('password', $json['errors']); // Passwords do not match $data = [ 'login' => 'test', 'password' => 'test', 'password_confirmation' => 'test2', ]; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('password', $json['errors']); // Login too short $data = [ 'login' => '1', 'password' => 'test', 'password_confirmation' => 'test', ]; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('login', $json['errors']); // Login invalid $data = [ 'login' => 'żżżżż', 'password' => 'test', 'password_confirmation' => 'test', ]; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('login', $json['errors']); // Data with invalid short_code $data = [ 'login' => 'TestLogin', 'password' => 'test', 'password_confirmation' => 'test', 'code' => $result['code'], 'short_code' => 'XXXX', ]; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('short_code', $json['errors']); } /** * Test last signup step with valid input (user creation) * * @depends testSignupVerifyValidInput * @return void */ public function testSignupValidInput(array $result) { $identity = \strtolower('SignupLogin@') . \config('app.domain'); $code = SignupCode::find($result['code']); $data = [ 'login' => 'SignupLogin', 'password' => 'test', 'password_confirmation' => 'test', 'code' => $code->code, 'short_code' => $code->short_code, ]; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(200); $this->assertCount(4, $json); $this->assertSame('success', $json['status']); $this->assertSame('bearer', $json['token_type']); $this->assertTrue(!empty($json['expires_in']) && is_int($json['expires_in']) && $json['expires_in'] > 0); $this->assertNotEmpty($json['access_token']); // Check if the code has been removed $this->assertNull(SignupCode::where($result['code'])->first()); // Check if the user has been created $user = User::where('email', $identity)->first(); $this->assertNotEmpty($user); $this->assertSame($identity, $user->email); $this->assertSame($result['name'], $user->name); // Check external email in user settings - $this->assertSame($result['email'], $user->getSetting('external_email', 'not set')); + $this->assertSame($result['email'], $user->getSetting('external_email')); // TODO: Check if the access token works } /** * List of email address validation cases for testValidateEmail() * * @return array Arguments for testValidateEmail() */ public function dataValidateEmail() { // To access config from dataProvider method we have to refreshApplication() first $this->refreshApplication(); $domain = \config('app.domain'); return [ // general cases (invalid) ['', false, 'validation.emailinvalid'], ['example.org', false, 'validation.emailinvalid'], ['@example.org', false, 'validation.emailinvalid'], ['test@localhost', false, 'validation.emailinvalid'], // general cases (valid) ['test@domain.tld', false, null], ['&@example.org', false, null], // kolab identity cases ['admin@' . $domain, true, 'validation.emailexists'], ['administrator@' . $domain, true, 'validation.emailexists'], ['sales@' . $domain, true, 'validation.emailexists'], ['root@' . $domain, true, 'validation.emailexists'], ['&@' . $domain, true, 'validation.emailinvalid'], ['testnonsystemdomain@invalid.tld', true, 'validation.emailinvalid'], // existing account ['SignupControllerTest1@' . $domain, true, 'validation.emailexists'], // valid for signup ['test.test@' . $domain, true, null], ['test_test@' . $domain, true, null], ['test-test@' . $domain, true, null], ]; } /** * Signup email validation. * * Note: Technicly these are mostly 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($email, $signup, $expected_result) { $method = new \ReflectionMethod('App\Http\Controllers\API\SignupController', 'validateEmail'); $method->setAccessible(true); $is_phone = false; $result = $method->invoke(new SignupController(), $email, $signup); $this->assertSame($expected_result, $result); } } diff --git a/src/tests/Feature/Controller/UsersTest.php b/src/tests/Feature/Controller/UsersTest.php index 0cfd399f..95e46ac9 100644 --- a/src/tests/Feature/Controller/UsersTest.php +++ b/src/tests/Feature/Controller/UsersTest.php @@ -1,80 +1,79 @@ 'UsersControllerTest1@UsersControllerTest.com' ] ); $user->delete(); } /** - {@inheritDoc} - - @return void + * {@inheritDoc} + * + * @return void */ public function tearDown(): void { $user = User::firstOrCreate( [ 'email' => 'UsersControllerTest1@UsersControllerTest.com' ] ); $user->delete(); parent::tearDown(); } public function testListUsers() { $user = User::firstOrCreate( [ 'email' => 'UsersControllerTest1@UsersControllerTest.com' ] ); $response = $this->actingAs($user)->get("api/v4/users"); $response->assertJsonCount(1); $response->assertStatus(200); } public function testUserEntitlements() { $userA = User::firstOrCreate( [ 'email' => 'UserEntitlement2A@UserEntitlement.com' ] ); $response = $this->actingAs($userA, 'api')->get("/api/v4/users/{$userA->id}"); $response->assertStatus(200); $response->assertJson(['id' => $userA->id]); $user = factory(User::class)->create(); $response = $this->actingAs($user)->get("/api/v4/users/{$userA->id}"); $response->assertStatus(404); } } diff --git a/src/tests/Feature/DomainTest.php b/src/tests/Feature/DomainTest.php index e1d87bb9..553fea35 100644 --- a/src/tests/Feature/DomainTest.php +++ b/src/tests/Feature/DomainTest.php @@ -1,76 +1,75 @@ first(); if ($domain) { $domain->delete(); } } } public function testDomainStatus() { $statuses = [ "new", "active", "confirmed", "suspended", "deleted" ]; $domains = \App\Utils::powerSet($statuses); foreach ($domains as $namespace_elements) { $namespace = implode('-', $namespace_elements) . '.com'; $status = 1; if (in_array("new", $namespace_elements)) { $status += Domain::STATUS_NEW; } if (in_array("active", $namespace_elements)) { $status += Domain::STATUS_ACTIVE; } if (in_array("confirmed", $namespace_elements)) { $status += Domain::STATUS_CONFIRMED; } if (in_array("suspended", $namespace_elements)) { $status += Domain::STATUS_SUSPENDED; } if (in_array("deleted", $namespace_elements)) { $status += Domain::STATUS_DELETED; } $domain = Domain::firstOrCreate( [ 'namespace' => $namespace, 'status' => $status, 'type' => Domain::TYPE_EXTERNAL ] ); $this->assertTrue($domain->status > 1); } } } diff --git a/src/tests/Feature/EntitlementTest.php b/src/tests/Feature/EntitlementTest.php index 5865c71a..0eb56532 100644 --- a/src/tests/Feature/EntitlementTest.php +++ b/src/tests/Feature/EntitlementTest.php @@ -1,114 +1,113 @@ 'entitlement-test@kolabnow.com'] ); $user = User::firstOrCreate( ['email' => 'entitled-user@custom-domain.com'] ); $entitlement = Entitlement::firstOrCreate( [ 'owner_id' => $owner->id, 'user_id' => $user->id ] ); $entitlement->delete(); $user->delete(); $owner->delete(); } public function testUserAddEntitlement() { $sku_domain = Sku::firstOrCreate( ['title' => 'domain'] ); $sku_mailbox = Sku::firstOrCreate( ['title' => 'mailbox'] ); $owner = User::firstOrCreate( ['email' => 'entitlement-test@kolabnow.com'] ); $user = User::firstOrCreate( ['email' => 'entitled-user@custom-domain.com'] ); $this->assertTrue($owner->id != $user->id); $wallets = $owner->wallets()->get(); $domain = Domain::firstOrCreate( [ 'namespace' => 'custom-domain.com', 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ] ); $entitlement_own_mailbox = Entitlement::firstOrCreate( [ 'owner_id' => $owner->id, 'entitleable_id' => $owner->id, 'entitleable_type' => User::class, 'wallet_id' => $wallets[0]->id, 'sku_id' => $sku_mailbox->id, 'description' => "Owner Mailbox Entitlement Test" ] ); $entitlement_domain = Entitlement::firstOrCreate( [ 'owner_id' => $owner->id, 'entitleable_id' => $domain->id, 'entitleable_type' => Domain::class, 'wallet_id' => $wallets[0]->id, 'sku_id' => $sku_domain->id, 'description' => "User Domain Entitlement Test" ] ); $entitlement_mailbox = Entitlement::firstOrCreate( [ 'owner_id' => $owner->id, 'entitleable_id' => $user->id, 'entitleable_type' => User::class, 'wallet_id' => $wallets[0]->id, 'sku_id' => $sku_mailbox->id, 'description' => "User Mailbox Entitlement Test" ] ); $owner->addEntitlement($entitlement_own_mailbox); $owner->addEntitlement($entitlement_domain); $owner->addEntitlement($entitlement_mailbox); $this->assertTrue($owner->entitlements()->count() == 3); $this->assertTrue($sku_domain->entitlements()->count() == 2); $this->assertTrue($sku_mailbox->entitlements()->count() == 3); $this->assertTrue($wallets[0]->entitlements()->count() == 3); $this->assertTrue($wallets[0]->fresh()->balance < 0.00); } } diff --git a/src/tests/Feature/Jobs/PasswordResetEmailTest.php b/src/tests/Feature/Jobs/PasswordResetEmailTest.php new file mode 100644 index 00000000..5012864d --- /dev/null +++ b/src/tests/Feature/Jobs/PasswordResetEmailTest.php @@ -0,0 +1,71 @@ + 'PasswordReset@UserAccount.com' + ]); + $this->code = new VerificationCode([ + 'mode' => 'password-reset', + ]); + + $user->verificationcodes()->save($this->code); + $user->setSettings(['external_email' => 'etx@email.com']); + } + + /** + * {@inheritDoc} + * + * @return void + */ + public function tearDown(): void + { + $this->code->user->delete(); + + parent::tearDown(); + } + + /** + * Test job handle + * + * @return void + */ + public function testPasswordResetEmailHandle() + { + Mail::fake(); + + // Assert that no jobs were pushed... + Mail::assertNothingSent(); + + $job = new PasswordResetEmail($this->code); + $job->handle(); + + // Assert the email sending job was pushed once + Mail::assertSent(PasswordReset::class, 1); + + // Assert the mail was sent to the code's email + Mail::assertSent(PasswordReset::class, function ($mail) { + return $mail->hasTo($this->code->user->getSetting('external_email')); + }); + } +} diff --git a/src/tests/Feature/Jobs/SignupVerificationEmailTest.php b/src/tests/Feature/Jobs/SignupVerificationEmailTest.php index 44477eef..4dfbc3bf 100644 --- a/src/tests/Feature/Jobs/SignupVerificationEmailTest.php +++ b/src/tests/Feature/Jobs/SignupVerificationEmailTest.php @@ -1,68 +1,67 @@ code = SignupCode::create([ 'data' => [ 'email' => 'SignupVerificationEmailTest1@' . \config('app.domain'), 'name' => "Test Job" ] ]); } /** * {@inheritDoc} * * @return void */ public function tearDown(): void { $this->code->delete(); parent::tearDown(); } /** * Test job handle * * @return void */ public function testSignupVerificationEmailHandle() { Mail::fake(); // Assert that no jobs were pushed... Mail::assertNothingSent(); $job = new SignupVerificationEmail($this->code); $job->handle(); // Assert the email sending job was pushed once Mail::assertSent(SignupVerification::class, 1); // Assert the mail was sent to the code's email Mail::assertSent(SignupVerification::class, function ($mail) { return $mail->hasTo($this->code->data['email']); }); } } diff --git a/src/tests/Feature/SignupCodeTest.php b/src/tests/Feature/SignupCodeTest.php index 86efdac2..fea7b66f 100644 --- a/src/tests/Feature/SignupCodeTest.php +++ b/src/tests/Feature/SignupCodeTest.php @@ -1,43 +1,42 @@ [ 'email' => 'User@email.org', 'name' => 'User Name', ] ]; $now = new \DateTime('now'); $code = SignupCode::create($data); $this->assertFalse($code->isExpired()); $this->assertTrue(strlen($code->code) === SignupCode::CODE_LENGTH); - $this->assertTrue(strlen($code->short_code) === env('SIGNUP_CODE_LENGTH', SignupCode::SHORTCODE_LENGTH)); + $this->assertTrue(strlen($code->short_code) === env('VERIFICATION_CODE_LENGTH', SignupCode::SHORTCODE_LENGTH)); $this->assertSame($data['data'], $code->data); $this->assertInstanceOf(\DateTime::class, $code->expires_at); $this->assertSame(env('SIGNUP_CODE_EXPIRY', SignupCode::CODE_EXP_HOURS), $code->expires_at->diff($now)->h + 1); $inst = SignupCode::find($code->code); $this->assertInstanceOf(SignupCode::class, $inst); $this->assertSame($inst->code, $code->code); } } diff --git a/src/tests/Feature/UserTest.php b/src/tests/Feature/UserTest.php index 65e2ce84..791b05c7 100644 --- a/src/tests/Feature/UserTest.php +++ b/src/tests/Feature/UserTest.php @@ -1,66 +1,70 @@ 'UserAccountA@UserAccount.com' ] ); $this->assertTrue($userA->wallets()->count() == 1); $userA->wallets()->each( function ($wallet) { $userB = User::firstOrCreate( [ 'email' => 'UserAccountB@UserAccount.com' ] ); $wallet->addController($userB); } ); $userB = User::firstOrCreate( [ 'email' => 'UserAccountB@UserAccount.com' ] ); $this->assertTrue($userB->accounts()->get()[0]->id === $userA->wallets()->get()[0]->id); } public function testUserDomains() { $user = User::firstOrCreate( [ 'email' => 'john@kolab.org' ] ); $domains = []; foreach ($user->domains() as $domain) { $domains[] = $domain->namespace; } $this->assertTrue(in_array('kolabnow.com', $domains)); $this->assertTrue(in_array('kolab.org', $domains)); } + + public function testFindByEmail() + { + $this->markTestIncomplete('TODO'); + } } diff --git a/src/tests/Feature/VerificationCodeTest.php b/src/tests/Feature/VerificationCodeTest.php new file mode 100644 index 00000000..3a179618 --- /dev/null +++ b/src/tests/Feature/VerificationCodeTest.php @@ -0,0 +1,46 @@ + 'UserAccountA@UserAccount.com']); + $data = [ + 'user_id' => $user->id, + 'mode' => 'password-reset', + ]; + + $now = new \DateTime('now'); + + $code = VerificationCode::create($data); + + $code_length = env('VERIFICATION_CODE_LENGTH', VerificationCode::SHORTCODE_LENGTH); + $code_exp_hrs = env('VERIFICATION_CODE_EXPIRY', VerificationCode::CODE_EXP_HOURS); + + $this->assertFalse($code->isExpired()); + $this->assertTrue(strlen($code->code) === VerificationCode::CODE_LENGTH); + $this->assertTrue(strlen($code->short_code) === $code_length); + $this->assertSame($data['mode'], $code->mode); + $this->assertSame($user->id, $code->user->id); + $this->assertInstanceOf(\DateTime::class, $code->expires_at); + $this->assertSame($code_exp_hrs, $code->expires_at->diff($now)->h + 1); + + $inst = VerificationCode::find($code->code); + + $this->assertInstanceOf(VerificationCode::class, $inst); + $this->assertSame($inst->code, $code->code); + } +} diff --git a/src/tests/Feature/WalletTest.php b/src/tests/Feature/WalletTest.php index 1c4be37a..7f71a64f 100644 --- a/src/tests/Feature/WalletTest.php +++ b/src/tests/Feature/WalletTest.php @@ -1,243 +1,242 @@ users as $user) { $_user = User::firstOrCreate(['email' => $user]); $_user->delete(); } } public function tearDown(): void { foreach ($this->users as $user) { $_user = User::firstOrCreate(['email' => $user]); $_user->delete(); } parent::tearDown(); } /** Verify a wallet is created, when a user is created. @return void */ public function testCreateUserCreatesWallet() { $user = User::firstOrCreate( [ 'email' => 'UserWallet1@UserWallet.com' ] ); $this->assertTrue($user->wallets()->count() == 1); } /** Verify a user can haz more wallets. @return void */ public function testAddWallet() { $user = User::firstOrCreate( [ 'email' => 'UserWallet2@UserWallet.com' ] ); $user->wallets()->save( new Wallet(['currency' => 'USD']) ); $this->assertTrue($user->wallets()->count() >= 2); $user->wallets()->each( function ($wallet) { $this->assertTrue($wallet->balance === 0.00); } ); } /** Verify we can not delete a user wallet that holds balance. @return void */ public function testDeleteWalletWithCredit() { $user = User::firstOrCreate( [ 'email' => 'UserWallet3@UserWallet.com' ] ); $user->wallets()->each( function ($wallet) { $wallet->credit(1.00)->save(); } ); $user->wallets()->each( function ($wallet) { $this->assertFalse($wallet->delete()); } ); } /** Verify we can not delete a wallet that is the last wallet. @return void */ public function testDeleteLastWallet() { $user = User::firstOrCreate( [ 'email' => 'UserWallet4@UserWallet.com' ] ); $this->assertTrue($user->wallets()->count() == 1); $user->wallets()->each( function ($wallet) { $this->assertFalse($wallet->delete()); } ); } /** Verify we can remove a wallet that is an additional wallet. @return void */ public function testDeleteAddtWallet() { $user = User::firstOrCreate( [ 'email' => 'UserWallet5@UserWallet.com' ] ); $user->wallets()->save( new Wallet(['currency' => 'USD']) ); $user->wallets()->each( function ($wallet) { if ($wallet->currency == 'USD') { $this->assertNotFalse($wallet->delete()); } } ); } /** Verify a wallet can be assigned a controller. @return void */ public function testAddWalletController() { $userA = User::firstOrCreate( [ 'email' => 'WalletControllerA@WalletController.com' ] ); $userA->wallets()->each( function ($wallet) { $userB = User::firstOrCreate( [ 'email' => 'WalletControllerB@WalletController.com' ] ); $wallet->addController($userB); } ); $userB = User::firstOrCreate( [ 'email' => 'WalletControllerB@WalletController.com' ] ); $this->assertTrue($userB->accounts()->count() == 1); $aWallet = $userA->wallets()->get(); $bAccount = $userB->accounts()->get(); $this->assertTrue($bAccount[0]->id === $aWallet[0]->id); } /** Verify controllers can also be removed from wallets. @return void */ public function testRemoveWalletController() { $userA = User::firstOrCreate( [ 'email' => 'WalletController2A@WalletController.com' ] ); $userA->wallets()->each( function ($wallet) { $userB = User::firstOrCreate( [ 'email' => 'WalletController2B@WalletController.com' ] ); $wallet->addController($userB); } ); $userB = User::firstOrCreate( [ 'email' => 'WalletController2B@WalletController.com' ] ); $userB->accounts()->each( function ($wallet) { $userB = User::firstOrCreate( [ 'email' => 'WalletController2B@WalletController.com' ] ); $wallet->removeController($userB); } ); $this->assertTrue($userB->accounts()->count() == 0); } } diff --git a/src/tests/Unit/DomainTest.php b/src/tests/Unit/DomainTest.php index 5e8833f1..433b4c5c 100644 --- a/src/tests/Unit/DomainTest.php +++ b/src/tests/Unit/DomainTest.php @@ -1,70 +1,69 @@ 'test.com', 'status' => \array_sum($domain_statuses), 'type' => Domain::TYPE_EXTERNAL ] ); $this->assertTrue($domain->isNew() === in_array(Domain::STATUS_NEW, $domain_statuses)); $this->assertTrue($domain->isActive() === in_array(Domain::STATUS_ACTIVE, $domain_statuses)); $this->assertTrue($domain->isConfirmed() === in_array(Domain::STATUS_CONFIRMED, $domain_statuses)); $this->assertTrue($domain->isSuspended() === in_array(Domain::STATUS_SUSPENDED, $domain_statuses)); $this->assertTrue($domain->isDeleted() === in_array(Domain::STATUS_DELETED, $domain_statuses)); } } /** * Test basic Domain funtionality */ public function testDomainType() { $types = [ Domain::TYPE_PUBLIC, Domain::TYPE_HOSTED, Domain::TYPE_EXTERNAL, ]; $domains = \App\Utils::powerSet($types); foreach ($domains as $domain_types) { $domain = new Domain( [ 'namespace' => 'test.com', 'status' => Domain::STATUS_NEW, 'type' => \array_sum($domain_types), ] ); $this->assertTrue($domain->isPublic() === in_array(Domain::TYPE_PUBLIC, $domain_types)); $this->assertTrue($domain->isHosted() === in_array(Domain::TYPE_HOSTED, $domain_types)); $this->assertTrue($domain->isExternal() === in_array(Domain::TYPE_EXTERNAL, $domain_types)); } } } diff --git a/src/tests/Unit/Mail/PasswordResetTest.php b/src/tests/Unit/Mail/PasswordResetTest.php new file mode 100644 index 00000000..cb5ff671 --- /dev/null +++ b/src/tests/Unit/Mail/PasswordResetTest.php @@ -0,0 +1,40 @@ + 123456789, + 'mode' => 'password-reset', + 'code' => 'code', + 'short_code' => 'short-code', + ]); + $code->user = new User([ + 'name' => 'User Name', + ]); + + $mail = new PasswordReset($code); + $html = $mail->build()->render(); + + $url = \config('app.url') . '/login/reset/' . $code->short_code . '-' . $code->code; + $link = "$url"; + + $this->assertSame(\config('app.name') . ' Password Reset', $mail->subject); + $this->assertStringStartsWith('', $html); + $this->assertTrue(strpos($html, $link) > 0); + $this->assertTrue(strpos($html, $code->user->name) > 0); + } +} diff --git a/src/tests/Unit/Mail/SignupVerificationTest.php b/src/tests/Unit/Mail/SignupVerificationTest.php index 603d1265..32138a21 100644 --- a/src/tests/Unit/Mail/SignupVerificationTest.php +++ b/src/tests/Unit/Mail/SignupVerificationTest.php @@ -1,39 +1,38 @@ 'code', 'short_code' => 'short-code', 'data' => [ 'email' => 'test@email', 'name' => 'Test Name', ], ]); $mail = new SignupVerification($code); $html = $mail->build()->render(); $url = \config('app.url') . '/signup/' . $code->short_code . '-' . $code->code; - $link = "$url"; + $link = "$url"; $this->assertSame(\config('app.name') . ' Registration', $mail->subject); $this->assertStringStartsWith('', $html); $this->assertTrue(strpos($html, $link) > 0); $this->assertTrue(strpos($html, $code->data['name']) > 0); } } diff --git a/src/tests/Unit/SignupCodeTest.php b/src/tests/Unit/SignupCodeTest.php index b326a813..ed2ed2fb 100644 --- a/src/tests/Unit/SignupCodeTest.php +++ b/src/tests/Unit/SignupCodeTest.php @@ -1,24 +1,23 @@ assertTrue(is_string($code)); $this->assertTrue(strlen($code) === env('SIGNUP_CODE_LENGTH', SignupCode::SHORTCODE_LENGTH)); $this->assertTrue(strspn($code, env('SIGNUP_CODE_CHARS', SignupCode::SHORTCODE_CHARS)) === strlen($code)); } } diff --git a/src/tests/Unit/UtilsTest.php b/src/tests/Unit/UtilsTest.php index 495471f1..7f10ef3b 100644 --- a/src/tests/Unit/UtilsTest.php +++ b/src/tests/Unit/UtilsTest.php @@ -1,84 +1,83 @@ assertIsArray($result); $this->assertCount(0, $result); $set = ["a1"]; $result = \App\Utils::powerSet($set); $this->assertIsArray($result); $this->assertCount(1, $result); $this->assertTrue(in_array(["a1"], $result)); $set = ["a1", "a2"]; $result = \App\Utils::powerSet($set); $this->assertIsArray($result); $this->assertCount(3, $result); $this->assertTrue(in_array(["a1"], $result)); $this->assertTrue(in_array(["a2"], $result)); $this->assertTrue(in_array(["a1", "a2"], $result)); $set = ["a1", "a2", "a3"]; $result = \App\Utils::powerSet($set); $this->assertIsArray($result); $this->assertCount(7, $result); $this->assertTrue(in_array(["a1"], $result)); $this->assertTrue(in_array(["a2"], $result)); $this->assertTrue(in_array(["a3"], $result)); $this->assertTrue(in_array(["a1", "a2"], $result)); $this->assertTrue(in_array(["a1", "a3"], $result)); $this->assertTrue(in_array(["a2", "a3"], $result)); $this->assertTrue(in_array(["a1", "a2", "a3"], $result)); } /** * Test for Utils::uuidInt() * * @return void */ public function testUuidInt() { $result = Utils::uuidInt(); $this->assertTrue(is_int($result)); $this->assertTrue($result > 0); } /** * Test for Utils::uuidStr() * * @return void */ public function testUuidStr() { $result = Utils::uuidStr(); $this->assertTrue(is_string($result)); $this->assertTrue(strlen($result) === 36); $this->assertTrue(preg_match('/[^a-f0-9-]/i', $result) === 0); } } diff --git a/src/tests/Unit/VerificationCodeTest.php b/src/tests/Unit/VerificationCodeTest.php new file mode 100644 index 00000000..a1902dd6 --- /dev/null +++ b/src/tests/Unit/VerificationCodeTest.php @@ -0,0 +1,26 @@ +assertTrue(is_string($code)); + $this->assertTrue(strlen($code) === $code_length); + $this->assertTrue(strspn($code, $code_chars) === strlen($code)); + } +}