Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117764241
D976.1775222638.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
92 KB
Referenced Files
None
Subscribers
None
D976.1775222638.diff
View Options
diff --git a/src/app/Http/Controllers/API/SignupController.php b/src/app/Http/Controllers/API/SignupController.php
--- a/src/app/Http/Controllers/API/SignupController.php
+++ b/src/app/Http/Controllers/API/SignupController.php
@@ -7,6 +7,9 @@
use App\Jobs\SignupVerificationSMS;
use App\Domain;
use App\Plan;
+use App\Rules\ExternalEmail;
+use App\Rules\UserEmailDomain;
+use App\Rules\UserEmailLocal;
use App\SignupCode;
use App\User;
use Illuminate\Http\Request;
@@ -77,7 +80,7 @@
// Validate user email (or phone)
if ($error = $this->validatePhoneOrEmail($request->email, $is_phone)) {
- return response()->json(['status' => 'error', 'errors' => ['email' => __($error)]], 422);
+ return response()->json(['status' => 'error', 'errors' => ['email' => $error]], 422);
}
// Generate the verification code
@@ -187,8 +190,7 @@
$domain = $request->domain;
// Validate login
- if ($errors = $this->validateLogin($login, $domain, $is_domain)) {
- $errors = $this->resolveErrors($errors);
+ if ($errors = self::validateLogin($login, $domain, $is_domain)) {
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
@@ -234,56 +236,62 @@
}
/**
- * Checks if the input string is a valid email address or a phone number
- *
- * @param string $input Email address or phone number
- * @param bool $is_phone Will have been set to True if the string is valid phone number
+ * Returns plan for the signup process
*
- * @return string Error message label on validation error
+ * @returns \App\Plan Plan object selected for current signup process
*/
- protected function validatePhoneOrEmail($input, &$is_phone = false)
+ protected function getPlan()
{
- $is_phone = false;
-
- return $this->validateEmail($input);
-
- // TODO: Phone number support
-/*
- if (strpos($input, '@')) {
- return $this->validateEmail($input);
- }
+ if (!$this->plan) {
+ // Get the plan if specified and exists...
+ if ($this->code && $this->code->data['plan']) {
+ $plan = Plan::where('title', $this->code->data['plan'])->first();
+ }
- $input = str_replace(array('-', ' '), '', $input);
+ // ...otherwise use the default plan
+ if (empty($plan)) {
+ // TODO: Get default plan title from config
+ $plan = Plan::where('title', 'individual')->first();
+ }
- if (!preg_match('/^\+?[0-9]{9,12}$/', $input)) {
- return 'validation.noemailorphone';
+ $this->plan = $plan;
}
- $is_phone = true;
-*/
+ return $this->plan;
}
/**
- * Email address validation
+ * Checks if the input string is a valid email address or a phone number
*
- * @param string $email Email address
+ * @param string $input Email address or phone number
+ * @param bool $is_phone Will have been set to True if the string is valid phone number
*
- * @return string Error message label on validation error
+ * @return string Error message on validation error
*/
- protected function validateEmail($email)
+ protected static function validatePhoneOrEmail($input, &$is_phone = false): ?string
{
- $v = Validator::make(['email' => $email], ['email' => 'required|email']);
+ $is_phone = false;
+
+ $v = Validator::make(
+ ['email' => $input],
+ ['email' => ['required', 'string', new ExternalEmail()]]
+ );
if ($v->fails()) {
- return 'validation.emailinvalid';
+ return $v->errors()->toArray()['email'][0];
}
- list($local, $domain) = explode('@', $email);
+ // TODO: Phone number support
+/*
+ $input = str_replace(array('-', ' '), '', $input);
- // don't allow @localhost and other no-fqdn
- if (strpos($domain, '.') === false) {
- return 'validation.emailinvalid';
+ if (!preg_match('/^\+?[0-9]{9,12}$/', $input)) {
+ return \trans('validation.noemailorphone');
}
+
+ $is_phone = true;
+*/
+ return null;
}
/**
@@ -295,90 +303,45 @@
*
* @return array Error messages on validation error
*/
- protected function validateLogin($login, $domain, $external = false)
+ protected static function validateLogin($login, $domain, $external = false): ?array
{
- // don't allow @localhost and other no-fqdn
- if (empty($domain) || strpos($domain, '.') === false || stripos($domain, 'www.') === 0) {
- return ['domain' => 'validation.domaininvalid'];
- }
+ // Validate login part alone
+ $v = Validator::make(
+ ['login' => $login],
+ ['login' => ['required', 'string', new UserEmailLocal($external)]]
+ );
- // Local part validation
- if (!preg_match('/^[A-Za-z0-9_.-]+$/', $login)) {
- return ['login' => 'validation.logininvalid'];
+ if ($v->fails()) {
+ return ['login' => $v->errors()->toArray()['login'][0]];
}
- $domain = Str::lower($domain);
+ $domains = $external ? null : Domain::getPublicDomains();
- if (!$external) {
- // Check if the local part is not one of exceptions
- $exceptions = '/^(admin|administrator|sales|root)$/i';
- if (preg_match($exceptions, $login)) {
- return ['login' => 'validation.loginexists'];
- }
+ // Validate the domain
+ $v = Validator::make(
+ ['domain' => $domain],
+ ['domain' => ['required', 'string', new UserEmailDomain($domains)]]
+ );
- // Check if specified domain is allowed for signup
- if (!in_array($domain, Domain::getPublicDomains())) {
- return ['domain' => 'validation.domaininvalid'];
- }
- } else {
- // Use email validator to validate the domain part
- $v = Validator::make(['email' => 'user@' . $domain], ['email' => 'required|email']);
- if ($v->fails()) {
- return ['domain' => 'validation.domaininvalid'];
- }
+ if ($v->fails()) {
+ return ['domain' => $v->errors()->toArray()['domain'][0]];
+ }
- // TODO: DNS registration check - maybe after signup?
+ $domain = Str::lower($domain);
- // Check if domain is already registered with us
+ // Check if domain is already registered with us
+ if ($external) {
if (Domain::where('namespace', $domain)->first()) {
- return ['domain' => 'validation.domainexists'];
+ return ['domain' => \trans('validation.domainexists')];
}
}
// Check if user with specified login already exists
- // TODO: Aliases
$email = $login . '@' . $domain;
if (User::findByEmail($email)) {
- return ['login' => 'validation.loginexists'];
- }
- }
-
- /**
- * Returns plan for the signup process
- *
- * @returns \App\Plan Plan object selected for current signup process
- */
- protected function getPlan()
- {
- if (!$this->plan) {
- // Get the plan if specified and exists...
- if ($this->code && $this->code->data['plan']) {
- $plan = Plan::where('title', $this->code->data['plan'])->first();
- }
-
- // ...otherwise use the default plan
- if (empty($plan)) {
- // TODO: Get default plan title from config
- $plan = Plan::where('title', 'individual')->first();
- }
-
- $this->plan = $plan;
- }
-
- return $this->plan;
- }
-
- /**
- * Convert error labels to actual (localized) text
- */
- protected function resolveErrors(array $errors): array
- {
- $result = [];
-
- foreach ($errors as $idx => $label) {
- $result[$idx] = __($label);
+ return ['login' => \trans('validation.loginexists')];
}
- return $result;
+ return null;
}
}
diff --git a/src/app/Http/Controllers/API/UsersController.php b/src/app/Http/Controllers/API/UsersController.php
--- a/src/app/Http/Controllers/API/UsersController.php
+++ b/src/app/Http/Controllers/API/UsersController.php
@@ -4,10 +4,14 @@
use App\Http\Controllers\Controller;
use App\Domain;
+use App\Rules\UserEmailDomain;
+use App\Rules\UserEmailLocal;
use App\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
+use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
+use Illuminate\Support\Str;
class UsersController extends Controller
{
@@ -75,18 +79,7 @@
public function info()
{
$user = $this->guard()->user();
- $response = $user->toArray();
-
- // Settings
- // TODO: It might be reasonable to limit the list of settings here to these
- // that are safe and are used in the UI
- $response['settings'] = [];
- foreach ($user->settings as $item) {
- $response['settings'][$item->key] = $item->value;
- }
-
- // Status info
- $response['statusInfo'] = self::statusInfo($user);
+ $response = $this->userResponse($user);
return response()->json($response);
}
@@ -112,7 +105,6 @@
return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
}
-
$credentials = $request->only('email', 'password');
if ($token = $this->guard()->attempt($credentials)) {
@@ -181,10 +173,12 @@
$user = User::find($id);
if (empty($user)) {
- return $this->errorResponse(404);
+ return $this->errorResponse(404);
}
- return response()->json($user);
+ $response = $this->userResponse($user);
+
+ return response()->json($response);
}
/**
@@ -216,7 +210,7 @@
$domain = Domain::where('namespace', $domain)->first();
// If that is not a public domain, add domain specific steps
- if (!$domain->isPublic()) {
+ if ($domain && !$domain->isPublic()) {
$steps['domain-new'] = true;
$steps['domain-ldap-ready'] = 'isLdapReady';
$steps['domain-verified'] = 'isVerified';
@@ -255,7 +249,45 @@
*/
public function store(Request $request)
{
- // TODO
+ if ($this->guard()->user()->controller()->id !== $this->guard()->user()->id) {
+ return $this->errorResponse(403);
+ }
+
+ if ($error_response = $this->validateUserRequest($request, null, $settings)) {
+ return $error_response;
+ }
+
+ $user_name = !empty($settings['first_name']) ? $settings['first_name'] : '';
+ if (!empty($settings['last_name'])) {
+ $user_name .= ' ' . $settings['last_name'];
+ }
+
+ DB::beginTransaction();
+
+ // Create user record
+ $user = User::create([
+ 'name' => $user_name,
+ 'email' => $request->email,
+ 'password' => $request->password,
+ ]);
+
+ if (!empty($settings)) {
+ $user->setSettings($settings);
+ }
+
+ // TODO: Assign package
+
+ // Add aliases
+ if (!empty($request->aliases)) {
+ $user->setAliases($request->aliases);
+ }
+
+ DB::commit();
+
+ return response()->json([
+ 'status' => 'success',
+ 'message' => __('app.user-create-success'),
+ ]);
}
/**
@@ -278,41 +310,29 @@
return $this->errorResponse(404);
}
- $rules = [
- 'external_email' => 'nullable|email',
- 'phone' => 'string|nullable|max:64|regex:/^[0-9+() -]+$/',
- 'first_name' => 'string|nullable|max:512',
- 'last_name' => 'string|nullable|max:512',
- 'billing_address' => 'string|nullable|max:1024',
- 'country' => 'string|nullable|alpha|size:2',
- 'currency' => 'string|nullable|alpha|size:3',
- ];
-
- if (!empty($request->password) || !empty($request->password_confirmation)) {
- $rules['password'] = 'required|min:4|max:2048|confirmed';
+ if ($error_response = $this->validateUserRequest($request, $user, $settings)) {
+ return $error_response;
}
- // Validate input
- $v = Validator::make($request->all(), $rules);
-
- if ($v->fails()) {
- return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
- }
-
- // Update user settings
- $settings = $request->only(array_keys($rules));
- unset($settings['password']);
+ DB::beginTransaction();
if (!empty($settings)) {
$user->setSettings($settings);
}
// Update user password
- if (!empty($rules['password'])) {
+ if (!empty($request->password)) {
$user->password = $request->password;
$user->save();
}
+ // Update aliases
+ if (isset($request->aliases)) {
+ $user->setAliases($request->aliases);
+ }
+
+ DB::commit();
+
return response()->json([
'status' => 'success',
'message' => __('app.user-update-success'),
@@ -345,4 +365,181 @@
return $current_user->id == $user_id;
}
+
+ /**
+ * Create a response data array for specified user.
+ *
+ * @param \App\User $user User object
+ *
+ * @return array Response data
+ */
+ protected function userResponse(User $user): array
+ {
+ $response = $user->toArray();
+
+ // Settings
+ // TODO: It might be reasonable to limit the list of settings here to these
+ // that are safe and are used in the UI
+ $response['settings'] = [];
+ foreach ($user->settings as $item) {
+ $response['settings'][$item->key] = $item->value;
+ }
+
+ // Aliases
+ $response['aliases'] = [];
+ foreach ($user->aliases as $item) {
+ $response['aliases'][] = $item->alias;
+ }
+
+ // Status info
+ $response['statusInfo'] = self::statusInfo($user);
+
+ return $response;
+ }
+
+ /**
+ * Validate user input
+ *
+ * @param \Illuminate\Http\Request $request The API request.
+ * @param \App\User|null $user User identifier
+ * @param array $settings User settings (from the request)
+ *
+ * @return \Illuminate\Http\JsonResponse The response on error
+ */
+ protected function validateUserRequest(Request $request, $user, &$settings = [])
+ {
+ $rules = [
+ 'external_email' => 'nullable|email',
+ 'phone' => 'string|nullable|max:64|regex:/^[0-9+() -]+$/',
+ 'first_name' => 'string|nullable|max:512',
+ 'last_name' => 'string|nullable|max:512',
+ 'billing_address' => 'string|nullable|max:1024',
+ 'country' => 'string|nullable|alpha|size:2',
+ 'currency' => 'string|nullable|alpha|size:3',
+ 'aliases' => 'array|nullable',
+ ];
+
+ if (empty($user) || !empty($request->password) || !empty($request->password_confirmation)) {
+ $rules['password'] = 'required|min:4|max:2048|confirmed';
+ }
+
+ $errors = [];
+
+ // Validate input
+ $v = Validator::make($request->all(), $rules);
+
+ if ($v->fails()) {
+ $errors = $v->errors()->toArray();
+ }
+
+ $controller = $user ? $user->controller() : $this->guard()->user();
+
+ // For new user validate email address
+ if (empty($user)) {
+ $email = $request->email;
+
+ if (empty($email)) {
+ $errors['email'] = \trans('validation.required', ['attribute' => 'email']);
+ } elseif ($error = self::validateEmail($email, $controller, false)) {
+ $errors['email'] = $error;
+ }
+ }
+
+ // Validate aliases input
+ if (isset($request->aliases)) {
+ $aliases = [];
+ $existing_aliases = $user ? $user->aliases()->get()->pluck('alias')->toArray() : [];
+
+ foreach ($request->aliases as $idx => $alias) {
+ if (is_string($alias) && !empty($alias)) {
+ // Alias cannot be the same as the email address (new user)
+ if (!empty($email) && Str::lower($alias) == Str::lower($email)) {
+ continue;
+ }
+
+ // validate new aliases
+ if (
+ !in_array($alias, $existing_aliases)
+ && ($error = self::validateEmail($alias, $controller, true))
+ ) {
+ if (!isset($errors['aliases'])) {
+ $errors['aliases'] = [];
+ }
+ $errors['aliases'][$idx] = $error;
+ continue;
+ }
+
+ $aliases[] = $alias;
+ }
+ }
+
+ $request->aliases = $aliases;
+ }
+
+ if (!empty($errors)) {
+ return response()->json(['status' => 'error', 'errors' => $errors], 422);
+ }
+
+ // Update user settings
+ $settings = $request->only(array_keys($rules));
+ unset($settings['password'], $settings['aliases'], $settings['email']);
+ }
+
+ /**
+ * Email address (login or alias) validation
+ *
+ * @param string $email Email address
+ * @param \App\User $user The account owner
+ * @param bool $is_alias The email is an alias
+ *
+ * @return string Error message on validation error
+ */
+ protected static function validateEmail(string $email, User $user, bool $is_alias = false): ?string
+ {
+ $attribute = $is_alias ? 'alias' : 'email';
+
+ if (strpos($email, '@') === false) {
+ return \trans('validation.entryinvalid', ['attribute' => $attribute]);
+ }
+
+ list($login, $domain) = explode('@', $email);
+
+ // Check if domain exists
+ $domain = Domain::where('namespace', Str::lower($domain))->first();
+
+ if (empty($domain)) {
+ return \trans('validation.domaininvalid');
+ }
+
+ // Validate login part alone
+ $v = Validator::make(
+ [$attribute => $login],
+ [$attribute => ['required', new UserEmailLocal(!$domain->isPublic())]]
+ );
+
+ if ($v->fails()) {
+ return $v->errors()->toArray()[$attribute][0];
+ }
+
+ // Check if it is one of domains available to the user
+ // TODO: We should have a helper that returns "flat" array with domain names
+ // I guess we could use pluck() somehow
+ $domains = array_map(
+ function ($domain) {
+ return $domain->namespace;
+ },
+ $user->domains()
+ );
+
+ if (!in_array($domain->namespace, $domains)) {
+ return \trans('validation.entryexists', ['attribute' => 'domain']);
+ }
+
+ // Check if user with specified address already exists
+ if (User::findByEmail($email)) {
+ return \trans('validation.entryexists', ['attribute' => $attribute]);
+ }
+
+ return null;
+ }
}
diff --git a/src/app/Observers/UserAliasObserver.php b/src/app/Observers/UserAliasObserver.php
new file mode 100644
--- /dev/null
+++ b/src/app/Observers/UserAliasObserver.php
@@ -0,0 +1,88 @@
+<?php
+
+namespace App\Observers;
+
+use App\User;
+use App\UserAlias;
+
+class UserAliasObserver
+{
+ /**
+ * Handle the "creating" event on an alias
+ *
+ * Ensures that there's no user with specified email.
+ *
+ * @param \App\UserAlias $alias The user email alias
+ *
+ * @return bool|null
+ */
+ public function creating(UserAlias $alias)
+ {
+ $alias->alias = \strtolower($alias->alias);
+
+ if (User::where('email', $alias->alias)->first()) {
+ \Log::error("Failed creating alias {$alias->alias}. User exists.");
+ return false;
+ }
+ }
+
+ /**
+ * Handle the user alias "created" event.
+ *
+ * @param \App\UserAlias $alias User email alias
+ *
+ * @return void
+ */
+ public function created(UserAlias $alias)
+ {
+ \App\Jobs\UserUpdate::dispatch($alias->user);
+ }
+
+ /**
+ * Handle the user setting "updated" event.
+ *
+ * @param \App\UserAlias $alias User email alias
+ *
+ * @return void
+ */
+ public function updated(UserAlias $alias)
+ {
+ \App\Jobs\UserUpdate::dispatch($alias->user);
+ }
+
+ /**
+ * Handle the user setting "deleted" event.
+ *
+ * @param \App\UserAlias $alias User email alias
+ *
+ * @return void
+ */
+ public function deleted(UserAlias $alias)
+ {
+ \App\Jobs\UserUpdate::dispatch($alias->user);
+ }
+
+ /**
+ * Handle the user alias "restored" event.
+ *
+ * @param \App\UserAlias $alias User email alias
+ *
+ * @return void
+ */
+ public function restored(UserAlias $alias)
+ {
+ // not used
+ }
+
+ /**
+ * Handle the user alias "force deleted" event.
+ *
+ * @param \App\UserAlias $alias User email alias
+ *
+ * @return void
+ */
+ public function forceDeleted(UserAlias $alias)
+ {
+ // not used
+ }
+}
diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php
--- a/src/app/Providers/AppServiceProvider.php
+++ b/src/app/Providers/AppServiceProvider.php
@@ -32,6 +32,7 @@
\App\SignupCode::observe(\App\Observers\SignupCodeObserver::class);
\App\Sku::observe(\App\Observers\SkuObserver::class);
\App\User::observe(\App\Observers\UserObserver::class);
+ \App\UserAlias::observe(\App\Observers\UserAliasObserver::class);
\App\UserSetting::observe(\App\Observers\UserSettingObserver::class);
\App\VerificationCode::observe(\App\Observers\VerificationCodeObserver::class);
\App\Wallet::observe(\App\Observers\WalletObserver::class);
diff --git a/src/app/Rules/ExternalEmail.php b/src/app/Rules/ExternalEmail.php
new file mode 100644
--- /dev/null
+++ b/src/app/Rules/ExternalEmail.php
@@ -0,0 +1,52 @@
+<?php
+
+namespace App\Rules;
+
+use Illuminate\Contracts\Validation\Rule;
+use Illuminate\Support\Facades\Validator;
+
+class ExternalEmail implements Rule
+{
+ private $message;
+
+ /**
+ * Determine if the validation rule passes.
+ *
+ * Email address validation with some more strict rules
+ * than the default Laravel's 'email' rule
+ *
+ * @param string $attribute Attribute name
+ * @param mixed $email Email address input
+ *
+ * @return bool
+ */
+ public function passes($attribute, $email): bool
+ {
+ $v = Validator::make(['email' => $email], ['email' => 'required|email']);
+
+ if ($v->fails()) {
+ $this->message = \trans('validation.emailinvalid');
+ return false;
+ }
+
+ list($local, $domain) = explode('@', $email);
+
+ // don't allow @localhost and other no-fqdn
+ if (strpos($domain, '.') === false) {
+ $this->message = \trans('validation.emailinvalid');
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Get the validation error message.
+ *
+ * @return string
+ */
+ public function message(): ?string
+ {
+ return $this->message;
+ }
+}
diff --git a/src/app/Rules/UserEmailDomain.php b/src/app/Rules/UserEmailDomain.php
new file mode 100644
--- /dev/null
+++ b/src/app/Rules/UserEmailDomain.php
@@ -0,0 +1,70 @@
+<?php
+
+namespace App\Rules;
+
+use Illuminate\Contracts\Validation\Rule;
+use Illuminate\Support\Facades\Validator;
+use Illuminate\Support\Str;
+
+class UserEmailDomain implements Rule
+{
+ private $message;
+ private $domains;
+
+ /**
+ * Class constructor.
+ *
+ * @param array|null $domains Allowed domains
+ */
+ public function __construct($domains = null)
+ {
+ $this->domains = $domains;
+ }
+
+ /**
+ * Determine if the validation rule passes.
+ *
+ * Validation of local part of an email address that's
+ * going to be user's login.
+ *
+ * @param string $attribute Attribute name
+ * @param mixed $domain Domain part of email address
+ *
+ * @return bool
+ */
+ public function passes($attribute, $domain): bool
+ {
+ // don't allow @localhost and other no-fqdn
+ if (empty($domain) || strpos($domain, '.') === false || stripos($domain, 'www.') === 0) {
+ $this->message = \trans('validation.domaininvalid');
+ return false;
+ }
+
+ $domain = Str::lower($domain);
+
+ // Use email validator to validate the domain part
+ $v = Validator::make(['email' => 'user@' . $domain], ['email' => 'required|email']);
+ if ($v->fails()) {
+ $this->message = \trans('validation.domaininvalid');
+ return false;
+ }
+
+ // Check if specified domain is allowed for signup
+ if (is_array($this->domains) && !in_array($domain, $this->domains)) {
+ $this->message = \trans('validation.domaininvalid');
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Get the validation error message.
+ *
+ * @return string
+ */
+ public function message(): ?string
+ {
+ return $this->message;
+ }
+}
diff --git a/src/app/Rules/UserEmailLocal.php b/src/app/Rules/UserEmailLocal.php
new file mode 100644
--- /dev/null
+++ b/src/app/Rules/UserEmailLocal.php
@@ -0,0 +1,72 @@
+<?php
+
+namespace App\Rules;
+
+use Illuminate\Contracts\Validation\Rule;
+use Illuminate\Support\Facades\Validator;
+
+class UserEmailLocal implements Rule
+{
+ private $message;
+ private $external;
+
+ /**
+ * Class constructor.
+ *
+ * @param bool $external The user in an external domain, or not
+ */
+ public function __construct(bool $external)
+ {
+ $this->external = $external;
+ }
+
+ /**
+ * Determine if the validation rule passes.
+ *
+ * Validation of local part of an email address that's
+ * going to be user's login.
+ *
+ * @param string $attribute Attribute name
+ * @param mixed $login Local part of email address
+ *
+ * @return bool
+ */
+ public function passes($attribute, $login): bool
+ {
+ // Strict validation
+ if (!preg_match('/^[A-Za-z0-9_.-]+$/', $login)) {
+ $this->message = \trans('validation.entryinvalid', ['attribute' => $attribute]);
+ return false;
+ }
+
+ // Standard email address validation
+ $v = Validator::make([$attribute => $login . '@test.com'], [$attribute => 'required|email']);
+ if ($v->fails()) {
+ $this->message = \trans('validation.entryinvalid', ['attribute' => $attribute]);
+ return false;
+ }
+
+ // Check if the local part is not one of exceptions
+ // (when signing up for an account in public domain
+ if (!$this->external) {
+ $exceptions = '/^(admin|administrator|sales|root)$/i';
+
+ if (preg_match($exceptions, $login)) {
+ $this->message = \trans('validation.entryexists', ['attribute' => $attribute]);
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Get the validation error message.
+ *
+ * @return string
+ */
+ public function message(): ?string
+ {
+ return $this->message;
+ }
+}
diff --git a/src/app/Traits/UserAliasesTrait.php b/src/app/Traits/UserAliasesTrait.php
new file mode 100644
--- /dev/null
+++ b/src/app/Traits/UserAliasesTrait.php
@@ -0,0 +1,41 @@
+<?php
+
+namespace App\Traits;
+
+use App\UserAlias;
+use Illuminate\Support\Facades\Cache;
+
+trait UserAliasesTrait
+{
+ /**
+ * A helper to update user aliases list.
+ *
+ * Example Usage:
+ *
+ * ```php
+ * $user = User::firstOrCreate(['email' => 'some@other.erg']);
+ * $user->setAliases(['alias1@other.org', 'alias2@other.org']);
+ * ```
+ *
+ * @param array $aliases An array of email addresses
+ *
+ * @return void
+ */
+ public function setAliases(array $aliases): void
+ {
+ $existing_aliases = $this->aliases()->get()->map(function ($alias) {
+ return $alias->alias;
+ })->toArray();
+
+ $aliases = array_map('strtolower', $aliases);
+ $aliases = array_unique($aliases);
+
+ foreach (array_diff($aliases, $existing_aliases) as $alias) {
+ $this->aliases()->create(['alias' => $alias]);
+ }
+
+ foreach (array_diff($existing_aliases, $aliases) as $alias) {
+ $this->aliases()->where('alias', $alias)->delete();
+ }
+ }
+}
diff --git a/src/app/User.php b/src/app/User.php
--- a/src/app/User.php
+++ b/src/app/User.php
@@ -2,24 +2,30 @@
namespace App;
+use App\UserAlias;
+use App\Traits\UserAliasesTrait;
+use App\Traits\UserSettingsTrait;
use Illuminate\Notifications\Notifiable;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Iatstuti\Database\Support\NullableFields;
use Tymon\JWTAuth\Contracts\JWTSubject;
-use App\Traits\UserSettingsTrait;
/**
* The eloquent definition of a User.
*
- * @property integer $id
- * @property integer $status
+ * @property string $email
+ * @property int $id
+ * @property string $name
+ * @property string $password
+ * @property int $status
*/
class User extends Authenticatable implements JWTSubject
{
use Notifiable;
use NullableFields;
+ use UserAliasesTrait;
use UserSettingsTrait;
use SoftDeletes;
@@ -96,6 +102,16 @@
}
/**
+ * Email aliases of this user.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\HasMany
+ */
+ public function aliases()
+ {
+ return $this->hasMany('App\UserAlias', 'user_id');
+ }
+
+ /**
* Assign a package to a user. The user should not have any existing entitlements.
*
* @param \App\Package $package The package to assign.
@@ -128,6 +144,26 @@
return $user;
}
+ /**
+ * Returns user controlling the current user (or self when it's the account owner)
+ *
+ * @return \App\User A user object
+ */
+ public function controller(): User
+ {
+ // FIXME: This is most likely not the best way to do this
+ $entitlement = \App\Entitlement::where([
+ 'entitleable_id' => $this->id,
+ 'entitleable_type' => User::class
+ ])->first();
+
+ if ($entitlement && $entitlement->owner_id != $this->id) {
+ return $entitlement->owner;
+ }
+
+ return $this;
+ }
+
public function assignPlan($plan, $domain = null)
{
$this->setSetting('plan_id', $plan->id);
@@ -216,7 +252,7 @@
*
* @param string $email Email address
*
- * @return \App\User User model object
+ * @return \App\User User model object if found
*/
public static function findByEmail(string $email): ?User
{
@@ -224,11 +260,23 @@
return null;
}
+ $email = \strtolower($email);
+
$user = self::where('email', $email)->first();
- // TODO: Aliases, External email
+ if ($user) {
+ return $user;
+ }
- return $user;
+ $alias = UserAlias::where('alias', $email)->first();
+
+ if ($alias) {
+ return $alias->user;
+ }
+
+ // TODO: External email
+
+ return null;
}
public function getJWTIdentifier()
diff --git a/src/app/UserAlias.php b/src/app/UserAlias.php
new file mode 100644
--- /dev/null
+++ b/src/app/UserAlias.php
@@ -0,0 +1,33 @@
+<?php
+
+namespace App;
+
+use Illuminate\Database\Eloquent\Model;
+
+/**
+ * A email address alias for a User.
+ *
+ * @property string $alias
+ * @property int $id
+ * @property int $user_id
+ */
+class UserAlias extends Model
+{
+ protected $fillable = [
+ 'user_id', 'alias'
+ ];
+
+ /**
+ * The user to which this alias belongs.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+ */
+ public function user()
+ {
+ return $this->belongsTo(
+ '\App\User',
+ 'user_id', /* local */
+ 'id' /* remote */
+ );
+ }
+}
diff --git a/src/database/migrations/2020_02_26_000000_create_user_aliases_table.php b/src/database/migrations/2020_02_26_000000_create_user_aliases_table.php
new file mode 100644
--- /dev/null
+++ b/src/database/migrations/2020_02_26_000000_create_user_aliases_table.php
@@ -0,0 +1,39 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+class CreateUserAliasesTable extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ Schema::create(
+ 'user_aliases',
+ function (Blueprint $table) {
+ $table->bigIncrements('id');
+ $table->bigInteger('user_id');
+ $table->string('alias')->unique();
+ $table->timestamps();
+
+ $table->foreign('user_id')->references('id')->on('users')
+ ->onDelete('cascade')->onUpdate('cascade');
+ }
+ );
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::dropIfExists('user_aliases');
+ }
+}
diff --git a/src/database/seeds/UserSeeder.php b/src/database/seeds/UserSeeder.php
--- a/src/database/seeds/UserSeeder.php
+++ b/src/database/seeds/UserSeeder.php
@@ -45,6 +45,8 @@
]
);
+ $john->setAliases(['john.doe@kolab.org']);
+
$user_wallets = $john->wallets()->get();
$package_domain = \App\Package::where('title', 'domain-hosting')->first();
@@ -71,6 +73,8 @@
]
);
+ $jack->setAliases(['jack.daniels@kolab.org']);
+
$john->assignPackage($package_kolab, $jack);
factory(User::class, 10)->create();
diff --git a/src/resources/js/app.js b/src/resources/js/app.js
--- a/src/resources/js/app.js
+++ b/src/resources/js/app.js
@@ -35,17 +35,46 @@
const input = $('#' + input_name)
if (input.length) {
- input.addClass('is-invalid')
- input.parent().find('.invalid-feedback').remove()
- input.parent().append($('<div class="invalid-feedback">')
- .text($.type(msg) === 'string' ? msg : msg.join(' ')))
+ // Create an error message\
+ // API responses can use a string, array or object
+ let msg_text = ''
+ if ($.type(msg) !== 'string') {
+ $.each(msg, (index, str) => {
+ msg_text += str + ' '
+ })
+ }
+ else {
+ msg_text = msg
+ }
+
+ let feedback = $('<div class="invalid-feedback">').text(msg_text)
+
+ if (input.is('.listinput')) {
+ // List input widget
+ let list = input.next('.listinput-widget')
+
+ list.children(':not(:first-child)').each((index, element) => {
+ if (msg[index]) {
+ $(element).find('input').addClass('is-invalid')
+ }
+ })
+
+ list.addClass('is-invalid').next('.invalid-feedback').remove()
+ list.after(feedback)
+ }
+ else {
+ // Standard form element
+ input.addClass('is-invalid')
+ input.parent().find('.invalid-feedback').remove()
+ input.parent().append(feedback)
+ }
return false
}
});
})
- $('form .is-invalid').first().focus()
+ $('form .is-invalid:not(.listinput-widget)').first().focus()
}
else if (error.response && error.response.data) {
error_msg = error.response.data.message
diff --git a/src/resources/lang/en/app.php b/src/resources/lang/en/app.php
--- a/src/resources/lang/en/app.php
+++ b/src/resources/lang/en/app.php
@@ -22,4 +22,5 @@
'domain-verify-success' => 'Domain verified successfully',
'user-update-success' => 'User data updated successfully',
+ 'user-create-success' => 'User created successfully',
];
diff --git a/src/resources/lang/en/validation.php b/src/resources/lang/en/validation.php
--- a/src/resources/lang/en/validation.php
+++ b/src/resources/lang/en/validation.php
@@ -117,14 +117,16 @@
'url' => 'The :attribute format is invalid.',
'uuid' => 'The :attribute must be a valid UUID.',
- 'emailinvalid' => 'The specified email address is invalid',
- 'domaininvalid' => 'The specified domain is invalid',
- 'logininvalid' => 'The specified login contains forbidden characters',
- 'loginexists' => 'The specified login is not available for signup',
- 'domainexists' => 'The specified domain is not available for signup',
- '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',
+ 'emailinvalid' => 'The specified email address is invalid.',
+ 'domaininvalid' => 'The specified domain is invalid.',
+ 'logininvalid' => 'The specified login is invalid.',
+ 'loginexists' => 'The specified login is not available.',
+ 'domainexists' => 'The specified domain is not available.',
+ '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.',
+ 'entryinvalid' => 'The specified :attribute is invalid.',
+ 'entryexists' => 'The specified :attribute is not available.',
/*
|--------------------------------------------------------------------------
@@ -155,5 +157,4 @@
*/
'attributes' => [],
-
];
diff --git a/src/resources/sass/app.scss b/src/resources/sass/app.scss
--- a/src/resources/sass/app.scss
+++ b/src/resources/sass/app.scss
@@ -78,3 +78,27 @@
font-size: 1.2rem;
font-weight: bold;
}
+
+.listinput {
+ display: none;
+}
+
+.listinput-widget {
+ & > div {
+ &:not(:last-child) {
+ margin-bottom: -1px;
+
+ input, a.btn {
+ border-bottom-right-radius: 0;
+ border-bottom-left-radius: 0;
+ }
+ }
+
+ &:not(:first-child) {
+ input, a.btn {
+ border-top-right-radius: 0;
+ border-top-left-radius: 0;
+ }
+ }
+ }
+}
diff --git a/src/resources/vue/components/User/Info.vue b/src/resources/vue/components/User/Info.vue
--- a/src/resources/vue/components/User/Info.vue
+++ b/src/resources/vue/components/User/Info.vue
@@ -2,8 +2,48 @@
<div class="container">
<div class="card" id="user-info">
<div class="card-body">
- <div class="card-title">User account</div>
+ <div class="card-title" v-if="user_id !== 'new'">User account</div>
+ <div class="card-title" v-if="user_id === 'new'">New user account</div>
<div class="card-text">
+ <form @submit.prevent="submit">
+ <div class="form-group row">
+ <label for="first_name" class="col-sm-4 col-form-label">First name</label>
+ <div class="col-sm-8">
+ <input type="text" class="form-control" id="first_name" v-model="user.first_name">
+ </div>
+ </div>
+ <div class="form-group row">
+ <label for="last_name" class="col-sm-4 col-form-label">Last name</label>
+ <div class="col-sm-8">
+ <input type="text" class="form-control" id="last_name" v-model="user.last_name">
+ </div>
+ </div>
+ <div class="form-group row">
+ <label for="email" class="col-sm-4 col-form-label">Email</label>
+ <div class="col-sm-8">
+ <input type="text" class="form-control" id="email" :disabled="user_id !== 'new'" required v-model="user.email">
+ </div>
+ </div>
+ <div class="form-group row">
+ <label for="aliases" class="col-sm-4 col-form-label">Email aliases</label>
+ <div class="col-sm-8">
+ <textarea class="form-control listinput" id="aliases"></textarea>
+ </div>
+ </div>
+ <div class="form-group row">
+ <label for="password" class="col-sm-4 col-form-label">Password</label>
+ <div class="col-sm-8">
+ <input type="password" class="form-control" id="password" v-model="user.password" :required="user_id === 'new'">
+ </div>
+ </div>
+ <div class="form-group row">
+ <label for="password_confirmaton" class="col-sm-4 col-form-label">Confirm password</label>
+ <div class="col-sm-8">
+ <input type="password" class="form-control" id="password_confirmation" v-model="user.password_confirmation" :required="user_id === 'new'">
+ </div>
+ </div>
+ <button class="btn btn-primary" type="submit">Submit</button>
+ </form>
</div>
</div>
</div>
@@ -15,19 +55,135 @@
data() {
return {
user_id: null,
- user: null
+ user: {}
}
},
created() {
- if (this.user_id = this.$route.params.user) {
+ this.user_id = this.$route.params.user
+
+ if (this.user_id === 'new') {
+ // do nothing (for now)
+ }
+ else {
axios.get('/api/v4/users/' + this.user_id)
.then(response => {
this.user = response.data
+ this.user.first_name = response.data.settings.first_name
+ this.user.last_name = response.data.settings.last_name
+ $('#aliases').val(response.data.aliases.join("\n"))
+ listinput('#aliases')
})
.catch(this.$root.errorHandler)
- } else {
- this.$root.errorPage(404)
+ }
+ },
+ mounted() {
+ if (this.user_id === 'new') {
+ listinput('#aliases')
+ }
+
+ $('#first_name').focus()
+ },
+ methods: {
+ submit() {
+ this.$root.clearFormValidation($('#user-info form'))
+
+ this.user.aliases = $('#aliases').val().split("\n")
+
+ let method = 'post'
+ let location = '/api/v4/users'
+
+ if (this.user_id !== 'new') {
+ method = 'put'
+ location += '/' + this.user_id
+ }
+
+ axios[method](location, this.user)
+ .then(response => {
+ delete this.user.password
+ delete this.user.password_confirm
+
+ if (response.data.status == 'success') {
+ this.$toastr('success', response.data.message)
+ }
+
+ // on new user redirect to users list
+ if (this.user_id === 'new') {
+ this.$route.push({ name: 'users' })
+ }
+ })
}
}
}
+
+ // List widget
+ // TODO: move it to a separate component file when needed
+ function listinput(elem)
+ {
+ elem = $(elem).addClass('listinput');
+
+ let widget = $('<div class="listinput-widget">')
+ let main_row = $('<div class="input-group">')
+ let wrap = $('<div class="input-group-append">')
+ let input = $('<input type="text" class="form-control main-input">')
+ let add_btn = $('<a href="#" class="btn btn-outline-secondary">').text('Add')
+
+ let update = () => {
+ let value = []
+
+ widget.find('input:not(.main-input)').each((index, input) => {
+ if (input.value) {
+ value.push(input.value)
+ }
+ })
+
+ elem.val(value.join("\n"))
+ }
+
+ let add_func = (value) => {
+ let row = $('<div class="input-group">')
+ let rinput = $('<input type="text" class="form-control">').val(value)
+ let rwrap = $('<div class="input-group-append">')
+ let del_btn = $('<a href="#" class="btn btn-outline-secondary">')
+ .text('Remove')
+ .on('click', e => {
+ row.remove()
+ input.focus()
+ update()
+ })
+
+ widget.append(row.append(rinput).append(rwrap.append(del_btn)))
+ }
+
+ // Create the widget and add to DOM
+ widget.append(main_row.append(input).append(wrap.append(add_btn)))
+ .insertAfter(elem)
+
+ // Add rows for every line in the original textarea
+ let value = $.trim(elem.val())
+ if (value.length) {
+ value.split("\n").forEach(add_func)
+ }
+
+ // Click handler on the Add button
+ add_btn.on('click', e => {
+ let value = input.val()
+
+ if (!value) {
+ return;
+ }
+
+ input.val('').focus();
+ add_func(value)
+ update()
+ })
+
+ // Enter key handler on main input
+ input.on('keydown', function(e) {
+ if (e.which == 13 && this.value) {
+ add_btn.click()
+ return false
+ }
+ })
+ }
+
</script>
diff --git a/src/resources/vue/components/User/List.vue b/src/resources/vue/components/User/List.vue
--- a/src/resources/vue/components/User/List.vue
+++ b/src/resources/vue/components/User/List.vue
@@ -4,6 +4,7 @@
<div class="card-body">
<div class="card-title">User Accounts</div>
<div class="card-text">
+ <router-link class="btn btn-primary create-user" :to="{ path: 'user/new' }" tag="button">Create user</router-link>
<table class="table table-hover">
<thead class="thead-light">
<tr>
diff --git a/src/tests/Browser/Components/ListInput.php b/src/tests/Browser/Components/ListInput.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Browser/Components/ListInput.php
@@ -0,0 +1,109 @@
+<?php
+
+namespace Tests\Browser\Components;
+
+use Laravel\Dusk\Browser;
+use Laravel\Dusk\Component as BaseComponent;
+use PHPUnit\Framework\Assert as PHPUnit;
+
+class ListInput extends BaseComponent
+{
+ protected $selector;
+
+
+ public function __construct($selector)
+ {
+ $this->selector = $selector;
+ }
+
+ /**
+ * Get the root selector for the component.
+ *
+ * @return string
+ */
+ public function selector()
+ {
+ return $this->selector . ' + .listinput-widget';
+ }
+
+ /**
+ * Assert that the browser page contains the component.
+ *
+ * @param Browser $browser
+ *
+ * @return void
+ */
+ public function assert(Browser $browser)
+ {
+// $list = explode("\n", $browser->value($this->selector));
+
+ $browser->waitFor($this->selector())
+ ->assertMissing($this->selector)
+ ->assertVisible('@input')
+ ->assertVisible('@add-btn');
+// ->assertListInputValue($list);
+ }
+
+ /**
+ * Get the element shortcuts for the component.
+ *
+ * @return array
+ */
+ public function elements()
+ {
+ return [
+ '@input' => '.input-group:first-child input',
+ '@add-btn' => '.input-group:first-child a.btn',
+ ];
+ }
+
+ /**
+ * Assert list input content
+ */
+ public function assertListInputValue(Browser $browser, array $list)
+ {
+ if (empty($list)) {
+ $browser->assertMissing('.input-group:not(:first-child)');
+ return;
+ }
+
+ foreach ($list as $idx => $value) {
+ $selector = '.input-group:nth-child(' . ($idx + 2) . ') input';
+ $browser->assertVisible($selector)->assertValue($selector, $value);
+ }
+ }
+
+ /**
+ * Add list entry
+ */
+ public function addListEntry(Browser $browser, string $value)
+ {
+ $browser->type('@input', $value)
+ ->click('@add-btn')
+ ->assertValue('.input-group:last-child input', $value);
+ }
+
+ /**
+ * Remove list entry
+ */
+ public function removeListEntry(Browser $browser, int $num)
+ {
+ $selector = '.input-group:nth-child(' . ($num + 1) . ') a.btn';
+ $browser->click($selector)->assertMissing($selector);
+ }
+
+ /**
+ * Assert an error message on the widget
+ */
+ public function assertFormError(Browser $browser, int $num, string $msg, bool $focused = false)
+ {
+ $selector = '.input-group:nth-child(' . ($num + 1) . ') input.is-invalid';
+
+ $browser->assertVisible($selector)
+ ->assertSeeIn(' + .invalid-feedback', $msg);
+
+ if ($focused) {
+ $browser->assertFocused($selector);
+ }
+ }
+}
diff --git a/src/tests/Browser/Components/Toast.php b/src/tests/Browser/Components/Toast.php
--- a/src/tests/Browser/Components/Toast.php
+++ b/src/tests/Browser/Components/Toast.php
@@ -14,6 +14,7 @@
public const TYPE_INFO = 'info';
protected $type;
+ protected $element;
public function __construct($type)
@@ -41,6 +42,7 @@
public function assert(Browser $browser)
{
$browser->waitFor($this->selector());
+ $this->element = $browser->element($this->selector());
}
/**
@@ -81,6 +83,6 @@
*/
public function closeToast(Browser $browser)
{
- $browser->click();
+ $this->element->click();
}
}
diff --git a/src/tests/Browser/Pages/UserInfo.php b/src/tests/Browser/Pages/UserInfo.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Browser/Pages/UserInfo.php
@@ -0,0 +1,44 @@
+<?php
+
+namespace Tests\Browser\Pages;
+
+use Laravel\Dusk\Browser;
+use Laravel\Dusk\Page;
+
+class UserInfo extends Page
+{
+ /**
+ * Get the URL for the page.
+ *
+ * @return string
+ */
+ public function url(): string
+ {
+ return '';
+ }
+
+ /**
+ * Assert that the browser is on the page.
+ *
+ * @param \Laravel\Dusk\Browser $browser
+ *
+ * @return void
+ */
+ public function assert(Browser $browser)
+ {
+ $browser->waitFor('@form');
+ }
+
+ /**
+ * Get the element shortcuts for the page.
+ *
+ * @return array
+ */
+ public function elements(): array
+ {
+ return [
+ '@app' => '#app',
+ '@form' => '#user-info form',
+ ];
+ }
+}
diff --git a/src/tests/Browser/Pages/UserList.php b/src/tests/Browser/Pages/UserList.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Browser/Pages/UserList.php
@@ -0,0 +1,46 @@
+<?php
+
+namespace Tests\Browser\Pages;
+
+use Laravel\Dusk\Browser;
+use Laravel\Dusk\Page;
+
+class UserList extends Page
+{
+ /**
+ * Get the URL for the page.
+ *
+ * @return string
+ */
+ public function url(): string
+ {
+ return '/users';
+ }
+
+ /**
+ * Assert that the browser is on the page.
+ *
+ * @param \Laravel\Dusk\Browser $browser
+ *
+ * @return void
+ */
+ public function assert(Browser $browser)
+ {
+ $browser->assertPathIs($this->url())
+ ->waitUntilMissing('@app .app-loader')
+ ->assertSeeIn('#user-list .card-title', 'User Accounts');
+ }
+
+ /**
+ * Get the element shortcuts for the page.
+ *
+ * @return array
+ */
+ public function elements(): array
+ {
+ return [
+ '@app' => '#app',
+ '@table' => '#user-list table',
+ ];
+ }
+}
diff --git a/src/tests/Browser/UsersTest.php b/src/tests/Browser/UsersTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Browser/UsersTest.php
@@ -0,0 +1,305 @@
+<?php
+
+namespace Tests\Browser;
+
+use App\User;
+use App\UserAlias;
+use Tests\Browser\Components\ListInput;
+use Tests\Browser\Components\Toast;
+use Tests\Browser\Pages\Dashboard;
+use Tests\Browser\Pages\Home;
+use Tests\Browser\Pages\UserInfo;
+use Tests\Browser\Pages\UserList;
+use Tests\DuskTestCase;
+use Laravel\Dusk\Browser;
+use Illuminate\Foundation\Testing\DatabaseMigrations;
+
+class UsersTest extends DuskTestCase
+{
+ private $profile = [
+ 'first_name' => 'John',
+ 'last_name' => 'Doe',
+ ];
+
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ // TODO: Use TestCase::deleteTestUser()
+ User::withTrashed()->where('email', 'john.rambo@kolab.org')->forceDelete();
+
+ $john = User::where('email', 'john@kolab.org')->first();
+ $john->setSettings($this->profile);
+ UserAlias::where('user_id', $john->id)
+ ->where('alias', 'john.test@kolab.org')->delete();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ // TODO: Use TestCase::deleteTestUser()
+ User::withTrashed()->where('email', 'john.rambo@kolab.org')->forceDelete();
+
+ $john = User::where('email', 'john@kolab.org')->first();
+ $john->setSettings($this->profile);
+ UserAlias::where('user_id', $john->id)
+ ->where('alias', 'john.test@kolab.org')->delete();
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test user info page (unauthenticated)
+ */
+ public function testInfoUnauth(): void
+ {
+ // Test that the page requires authentication
+ $this->browse(function (Browser $browser) {
+ $user = User::where('email', 'john@kolab.org')->first();
+
+ $browser->visit('/user/' . $user->id)->on(new Home());
+ });
+ }
+
+ /**
+ * Test users list page (unauthenticated)
+ */
+ public function testListUnauth(): void
+ {
+ // Test that the page requires authentication
+ $this->browse(function (Browser $browser) {
+ $browser->visit('/users')->on(new Home());
+ });
+
+ // TODO: Test that jack@kolab.org can't access this page
+ }
+
+ /**
+ * Test users list page
+ */
+ public function testList(): void
+ {
+ // Test that the page requires authentication
+ $this->browse(function (Browser $browser) {
+ $browser->visit(new Home())
+ ->submitLogon('john@kolab.org', 'simple123', true)
+ ->on(new Dashboard())
+ ->assertSeeIn('@links .link-users', 'User accounts')
+ ->click('@links .link-users')
+ ->on(new UserList())
+ ->whenAvailable('@table', function ($browser) {
+ $this->assertCount(1, $browser->elements('tbody tr'));
+ $browser->assertSeeIn('tbody tr td a', 'john@kolab.org');
+ });
+ });
+ }
+
+ /**
+ * Test user account editing page (not profile page)
+ *
+ * @depends testList
+ */
+ public function testInfo(): void
+ {
+ $this->browse(function (Browser $browser) {
+ $browser->on(new UserList())
+ ->click('@table tr:first-child a')
+ ->on(new UserInfo())
+ ->assertSeeIn('#user-info .card-title', 'User account')
+ ->with('@form', function (Browser $browser) {
+ // Assert form content
+ $browser->assertFocused('div.row:nth-child(1) input')
+ ->assertSeeIn('div.row:nth-child(1) label', 'First name')
+ ->assertValue('div.row:nth-child(1) input[type=text]', $this->profile['first_name'])
+ ->assertSeeIn('div.row:nth-child(2) label', 'Last name')
+ ->assertValue('div.row:nth-child(2) input[type=text]', $this->profile['last_name'])
+ ->assertSeeIn('div.row:nth-child(3) label', 'Email')
+ ->assertValue('div.row:nth-child(3) input[type=text]', 'john@kolab.org')
+//TODO ->assertDisabled('div.row:nth-child(3) input')
+ ->assertSeeIn('div.row:nth-child(4) label', 'Email aliases')
+ ->assertVisible('div.row:nth-child(4) .listinput-widget')
+ ->with(new ListInput('#aliases'), function (Browser $browser) {
+ $browser->assertListInputValue(['john.doe@kolab.org'])
+ ->assertValue('@input', '');
+ })
+ ->assertSeeIn('div.row:nth-child(5) label', 'Password')
+ ->assertValue('div.row:nth-child(5) input[type=password]', '')
+ ->assertSeeIn('div.row:nth-child(6) label', 'Confirm password')
+ ->assertValue('div.row:nth-child(6) input[type=password]', '')
+ ->assertSeeIn('button[type=submit]', 'Submit');
+
+ // Clear some fields and submit
+ $browser->type('#first_name', '')
+ ->type('#last_name', '')
+ ->click('button[type=submit]');
+ })
+ ->with(new Toast(Toast::TYPE_SUCCESS), function (Browser $browser) {
+ $browser->assertToastTitle('')
+ ->assertToastMessage('User data updated successfully')
+ ->closeToast();
+ });
+
+ // Test error handling (password)
+ $browser->with('@form', function (Browser $browser) {
+ $browser->type('#password', 'aaaaaa')
+ ->type('#password_confirmation', '')
+ ->click('button[type=submit]')
+ ->waitFor('#password + .invalid-feedback')
+ ->assertSeeIn('#password + .invalid-feedback', 'The password confirmation does not match.')
+ ->assertFocused('#password');
+ })
+ ->with(new Toast(Toast::TYPE_ERROR), function (Browser $browser) {
+ $browser->assertToastTitle('Error')
+ ->assertToastMessage('Form validation error')
+ ->closeToast();
+ });
+
+ // TODO: Test password change
+
+ // Test form error handling (aliases)
+ $browser->with('@form', function (Browser $browser) {
+ // TODO: For some reason, clearing the input value
+ // with ->type('#password', '') does not work, maybe some dusk/vue intricacy
+ // For now we just use the default password
+ $browser->type('#password', 'simple123')
+ ->type('#password_confirmation', 'simple123')
+ ->with(new ListInput('#aliases'), function (Browser $browser) {
+ $browser->addListEntry('invalid address');
+ })
+ ->click('button[type=submit]');
+ })
+ ->with(new Toast(Toast::TYPE_ERROR), function (Browser $browser) {
+ $browser->assertToastTitle('Error')
+ ->assertToastMessage('Form validation error')
+ ->closeToast();
+ })
+ ->with('@form', function (Browser $browser) {
+ $browser->with(new ListInput('#aliases'), function (Browser $browser) {
+ $browser->assertFormError(2, 'The specified alias is invalid.', false);
+ });
+ });
+
+ // Test adding aliases
+ $browser->with('@form', function (Browser $browser) {
+ $browser->with(new ListInput('#aliases'), function (Browser $browser) {
+ $browser->removeListEntry(2)
+ ->addListEntry('john.test@kolab.org');
+ })
+ ->click('button[type=submit]');
+ })
+ ->with(new Toast(Toast::TYPE_SUCCESS), function (Browser $browser) {
+ $browser->assertToastTitle('')
+ ->assertToastMessage('User data updated successfully')
+ ->closeToast();
+ });
+
+ $john = User::where('email', 'john@kolab.org')->first();
+ $alias = UserAlias::where('user_id', $john->id)->where('alias', 'john.test@kolab.org')->first();
+ $this->assertTrue(!empty($alias));
+ });
+ }
+
+ /**
+ * Test user adding page
+ *
+ * @depends testList
+ */
+ public function testNewUser(): void
+ {
+ $this->browse(function (Browser $browser) {
+ $browser->visit(new UserList())
+ ->assertSeeIn('button.create-user', 'Create user')
+ ->click('button.create-user')
+ ->on(new UserInfo())
+ ->assertSeeIn('#user-info .card-title', 'New user account')
+ ->with('@form', function (Browser $browser) {
+ // Assert form content
+ $browser->assertFocused('div.row:nth-child(1) input')
+ ->assertSeeIn('div.row:nth-child(1) label', 'First name')
+ ->assertValue('div.row:nth-child(1) input[type=text]', '')
+ ->assertSeeIn('div.row:nth-child(2) label', 'Last name')
+ ->assertValue('div.row:nth-child(2) input[type=text]', '')
+ ->assertSeeIn('div.row:nth-child(3) label', 'Email')
+ ->assertValue('div.row:nth-child(3) input[type=text]', '')
+ ->assertEnabled('div.row:nth-child(3) input')
+ ->assertSeeIn('div.row:nth-child(4) label', 'Email aliases')
+ ->assertVisible('div.row:nth-child(4) .listinput-widget')
+ ->with(new ListInput('#aliases'), function (Browser $browser) {
+ $browser->assertListInputValue([])
+ ->assertValue('@input', '');
+ })
+ ->assertSeeIn('div.row:nth-child(5) label', 'Password')
+ ->assertValue('div.row:nth-child(5) input[type=password]', '')
+ ->assertSeeIn('div.row:nth-child(6) label', 'Confirm password')
+ ->assertValue('div.row:nth-child(6) input[type=password]', '')
+ ->assertSeeIn('button[type=submit]', 'Submit');
+
+ // Test browser-side required fields and error handling
+ $browser->click('button[type=submit]')
+ ->assertFocused('#email')
+ ->type('#email', 'invalid email')
+ ->click('button[type=submit]')
+ ->assertFocused('#password')
+ ->type('#password', 'simple123')
+ ->click('button[type=submit]')
+ ->assertFocused('#password_confirmation')
+ ->type('#password_confirmation', 'simple')
+ ->click('button[type=submit]');
+ })
+ ->with(new Toast(Toast::TYPE_ERROR), function (Browser $browser) {
+ $browser->assertToastTitle('Error')
+ ->assertToastMessage('Form validation error')
+ ->closeToast();
+ })
+ ->with('@form', function (Browser $browser) {
+ $browser->assertSeeIn('#email + .invalid-feedback', 'The specified email is invalid.')
+ ->assertSeeIn('#password + .invalid-feedback', 'The password confirmation does not match.');
+ });
+
+ // Test form error handling (aliases)
+ $browser->with('@form', function (Browser $browser) {
+ $browser->type('#email', 'john.rambo@kolab.org')
+ ->type('#password_confirmation', 'simple123')
+ ->with(new ListInput('#aliases'), function (Browser $browser) {
+ $browser->addListEntry('invalid address');
+ })
+ ->click('button[type=submit]');
+ })
+ ->with(new Toast(Toast::TYPE_ERROR), function (Browser $browser) {
+ $browser->assertToastTitle('Error')
+ ->assertToastMessage('Form validation error')
+ ->closeToast();
+ })
+ ->with('@form', function (Browser $browser) {
+ $browser->with(new ListInput('#aliases'), function (Browser $browser) {
+ $browser->assertFormError(1, 'The specified alias is invalid.', false);
+ });
+ });
+
+ // Successful account creation
+ $browser->with('@form', function (Browser $browser) {
+ $browser->with(new ListInput('#aliases'), function (Browser $browser) {
+ $browser->removeListEntry(1)
+ ->addListEntry('john.rambo2@kolab.org');
+ })
+ ->click('button[type=submit]');
+ })
+ ->with(new Toast(Toast::TYPE_SUCCESS), function (Browser $browser) {
+ $browser->assertToastTitle('')
+ ->assertToastMessage('User created successfully')
+ ->closeToast();
+ });
+
+ // TODO: assert redirect to users list
+
+ $john = User::where('email', 'john.rambo@kolab.org')->first();
+ $alias = UserAlias::where('user_id', $john->id)->where('alias', 'john.rambo2@kolab.org')->first();
+ $this->assertTrue(!empty($alias));
+ });
+ }
+}
diff --git a/src/tests/Feature/Controller/SignupTest.php b/src/tests/Feature/Controller/SignupTest.php
--- a/src/tests/Feature/Controller/SignupTest.php
+++ b/src/tests/Feature/Controller/SignupTest.php
@@ -550,92 +550,52 @@
}
/**
- * List of email address validation cases for testValidateEmail()
- *
- * @return array Arguments for testValidateEmail()
- */
- public function dataValidateEmail()
- {
- return [
- // invalid
- ['', 'validation.emailinvalid'],
- ['example.org', 'validation.emailinvalid'],
- ['@example.org', 'validation.emailinvalid'],
- ['test@localhost', 'validation.emailinvalid'],
- // valid
- ['test@domain.tld', null],
- ['&@example.org', null],
- ];
- }
-
- /**
- * Signup email validation.
- *
- * Note: Technicly these are 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, $expected_result)
- {
- $method = new \ReflectionMethod('App\Http\Controllers\API\SignupController', 'validateEmail');
- $method->setAccessible(true);
-
- $result = $method->invoke(new SignupController(), $email);
-
- $this->assertSame($expected_result, $result);
- }
-
- /**
* List of login/domain validation cases for testValidateLogin()
*
* @return array Arguments for testValidateLogin()
*/
- public function dataValidateLogin()
+ public function dataValidateLogin(): array
{
$domain = $this->getPublicDomain();
return [
// Individual account
- ['', $domain, false, ['login' => 'validation.logininvalid']],
- ['test123456', 'localhost', false, ['domain' => 'validation.domaininvalid']],
- ['test123456', 'unknown-domain.org', false, ['domain' => 'validation.domaininvalid']],
+ ['', $domain, false, ['login' => 'The login field is required.']],
+ ['test123456', 'localhost', false, ['domain' => 'The specified domain is invalid.']],
+ ['test123456', 'unknown-domain.org', false, ['domain' => 'The specified domain is invalid.']],
['test.test', $domain, false, null],
['test_test', $domain, false, null],
['test-test', $domain, false, null],
- ['admin', $domain, false, ['login' => 'validation.loginexists']],
- ['administrator', $domain, false, ['login' => 'validation.loginexists']],
- ['sales', $domain, false, ['login' => 'validation.loginexists']],
- ['root', $domain, false, ['login' => 'validation.loginexists']],
+ ['admin', $domain, false, ['login' => 'The specified login is not available.']],
+ ['administrator', $domain, false, ['login' => 'The specified login is not available.']],
+ ['sales', $domain, false, ['login' => 'The specified login is not available.']],
+ ['root', $domain, false, ['login' => 'The specified login is not available.']],
- // existing user
- ['jack', 'kolab.org', true, ['domain' => 'validation.domainexists']],
+ // TODO existing (public domain) user
+ // ['signuplogin', $domain, false, ['login' => 'The specified login is not available.']],
// Domain account
['admin', 'kolabsys.com', true, null],
- ['testnonsystemdomain', 'invalid', true, ['domain' => 'validation.domaininvalid']],
- ['testnonsystemdomain', '.com', true, ['domain' => 'validation.domaininvalid']],
+ ['testnonsystemdomain', 'invalid', true, ['domain' => 'The specified domain is invalid.']],
+ ['testnonsystemdomain', '.com', true, ['domain' => 'The specified domain is invalid.']],
- // existing user
- ['john', 'kolab.org', true, ['domain' => 'validation.domainexists']],
+ // existing custom domain
+ ['jack', 'kolab.org', true, ['domain' => 'The specified domain is not available.']],
];
}
/**
* Signup login/domain validation.
*
- * Note: Technicly these include unit tests, but let's keep it here for now.
+ * Note: Technically these include unit tests, but let's keep it here for now.
* FIXME: Shall we do a http request for each case?
*
* @dataProvider dataValidateLogin
*/
- public function testValidateLogin($login, $domain, $external, $expected_result)
+ public function testValidateLogin($login, $domain, $external, $expected_result): void
{
- $method = new \ReflectionMethod('App\Http\Controllers\API\SignupController', 'validateLogin');
- $method->setAccessible(true);
+ $result = $this->invokeMethod(new SignupController(), 'validateLogin', [$login, $domain, $external]);
- $result = $method->invoke(new SignupController(), $login, $domain, $external);
-
- $this->assertSame($expected_result, $result, var_export(func_get_args(), true));
+ $this->assertSame($expected_result, $result);
}
}
diff --git a/src/tests/Feature/Controller/UsersTest.php b/src/tests/Feature/Controller/UsersTest.php
--- a/src/tests/Feature/Controller/UsersTest.php
+++ b/src/tests/Feature/Controller/UsersTest.php
@@ -20,6 +20,7 @@
$this->deleteTestUser('UsersControllerTest1@userscontroller.com');
$this->deleteTestUser('UserEntitlement2A@UserEntitlement.com');
+ $this->deleteTestUser('john2.doe2@kolab.org');
$this->deleteTestDomain('userscontroller.com');
}
@@ -30,6 +31,7 @@
{
$this->deleteTestUser('UsersControllerTest1@userscontroller.com');
$this->deleteTestUser('UserEntitlement2A@UserEntitlement.com');
+ $this->deleteTestUser('john2.doe2@kolab.org');
$this->deleteTestDomain('userscontroller.com');
parent::tearDown();
@@ -55,8 +57,10 @@
$this->assertEquals($user->email, $json['email']);
$this->assertEquals(User::STATUS_NEW, $json['status']);
$this->assertTrue(is_array($json['statusInfo']));
- $this->assertEquals($user->getSetting('country'), $json['settings']['country']);
- $this->assertEquals($user->getSetting('currency'), $json['settings']['currency']);
+ $this->assertTrue(is_array($json['settings']));
+ $this->assertTrue(is_array($json['aliases']));
+
+ // Note: Details of the content are tested in testUserResponse()
}
public function testIndex(): void
@@ -208,6 +212,29 @@
}
/**
+ * Test user data response used in show and info actions
+ */
+ public function testUserResponse(): void
+ {
+ $user = $this->getTestUser('john@kolab.org');
+
+ $result = $this->invokeMethod(new UsersController(), 'userResponse', [$user]);
+
+ $this->assertEquals($user->id, $result['id']);
+ $this->assertEquals($user->email, $result['email']);
+ $this->assertEquals($user->status, $result['status']);
+ $this->assertTrue(is_array($result['statusInfo']));
+
+ $this->assertTrue(is_array($result['aliases']));
+ $this->assertCount(1, $result['aliases']);
+ $this->assertSame('john.doe@kolab.org', $result['aliases'][0]);
+
+ $this->assertTrue(is_array($result['settings']));
+ $this->assertSame('US', $result['settings']['country']);
+ $this->assertSame('USD', $result['settings']['currency']);
+ }
+
+ /**
* Test fetching user data/profile (GET /api/v4/users/<user-id>)
*/
public function testShow(): void
@@ -217,8 +244,14 @@
// Test getting profile of self
$response = $this->actingAs($userA, 'api')->get("/api/v4/users/{$userA->id}");
+ $json = $response->json();
+
$response->assertStatus(200);
- $response->assertJson(['id' => $userA->id]);
+ $this->assertEquals($userA->id, $json['id']);
+ $this->assertEquals($userA->email, $json['email']);
+ $this->assertTrue(is_array($json['statusInfo']));
+ $this->assertTrue(is_array($json['settings']));
+ $this->assertTrue(is_array($json['aliases']));
// Test unauthorized access to a profile of other user
$user = $this->getTestUser('jack@kolab.org');
@@ -234,8 +267,90 @@
*/
public function testStore(): void
{
- // TODO
- $this->markTestIncomplete();
+ $jack = $this->getTestUser('jack@kolab.org');
+ $john = $this->getTestUser('john@kolab.org');
+
+ // Test empty request
+ $response = $this->actingAs($john)->post("/api/v4/users", []);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertSame("The email field is required.", $json['errors']['email']);
+ $this->assertSame("The password field is required.", $json['errors']['password'][0]);
+ $this->assertCount(2, $json);
+
+ // Test access by user not being a wallet controller
+ $post = ['first_name' => 'Test'];
+ $response = $this->actingAs($jack)->post("/api/v4/users", $post);
+ $json = $response->json();
+
+ $response->assertStatus(403);
+
+ $this->assertSame('error', $json['status']);
+ $this->assertSame("Access denied", $json['message']);
+ $this->assertCount(2, $json);
+
+ // Test some invalid data
+ $post = ['password' => '12345678', 'email' => 'invalid'];
+ $response = $this->actingAs($john)->post("/api/v4/users", $post);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertCount(2, $json);
+ $this->assertSame('The password confirmation does not match.', $json['errors']['password'][0]);
+ $this->assertSame('The specified email is invalid.', $json['errors']['email']);
+
+ // Test existing user email
+ $post = [
+ 'password' => 'simple',
+ 'password_confirmation' => 'simple',
+ 'first_name' => 'John2',
+ 'last_name' => 'Doe2',
+ 'email' => 'jack.daniels@kolab.org',
+ ];
+
+ $response = $this->actingAs($john)->post("/api/v4/users", $post);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertCount(2, $json);
+ $this->assertSame('The specified email is not available.', $json['errors']['email']);
+
+ // Test full user data
+ $post = [
+ 'password' => 'simple',
+ 'password_confirmation' => 'simple',
+ 'first_name' => 'John2',
+ 'last_name' => 'Doe2',
+ 'email' => 'john2.doe2@kolab.org',
+ 'aliases' => ['useralias1@kolab.org', 'useralias2@kolab.org']
+ ];
+
+ $response = $this->actingAs($john)->post("/api/v4/users", $post);
+ $json = $response->json();
+
+ $response->assertStatus(200);
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("User created successfully", $json['message']);
+ $this->assertCount(2, $json);
+
+ $user = User::where('email', 'john2.doe2@kolab.org')->first();
+ $this->assertInstanceOf(User::class, $user);
+ $this->assertSame('John2', $user->getSetting('first_name'));
+ $this->assertSame('Doe2', $user->getSetting('last_name'));
+ $aliases = $user->aliases()->orderBy('alias')->get();
+ $this->assertCount(2, $aliases);
+ $this->assertSame('useralias1@kolab.org', $aliases[0]->alias);
+ $this->assertSame('useralias2@kolab.org', $aliases[1]->alias);
+
+ // TODO: Test assigning a package to new user
}
/**
@@ -244,13 +359,17 @@
public function testUpdate(): void
{
$userA = $this->getTestUser('UsersControllerTest1@userscontroller.com');
- $userB = $this->getTestUser('jack@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $domain = $this->getTestDomain(
+ 'userscontroller.com',
+ ['status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL]
+ );
// Test unauthorized update of other user profile
- $response = $this->actingAs($userB)->get("/api/v4/users/{$userA->id}", []);
+ $response = $this->actingAs($jack)->get("/api/v4/users/{$userA->id}", []);
$response->assertStatus(403);
- // Test updating of self
+ // Test updating of self (empty request)
$response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", []);
$response->assertStatus(200);
@@ -283,6 +402,7 @@
'billing_address' => 'billing',
'country' => 'CH',
'currency' => 'CHF',
+ 'aliases' => ['useralias1@' . \config('app.domain'), 'useralias2@' . \config('app.domain')]
];
$response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post);
@@ -294,10 +414,14 @@
$this->assertSame("User data updated successfully", $json['message']);
$this->assertCount(2, $json);
$this->assertTrue($userA->password != $userA->fresh()->password);
- unset($post['password'], $post['password_confirmation']);
+ unset($post['password'], $post['password_confirmation'], $post['aliases']);
foreach ($post as $key => $value) {
$this->assertSame($value, $userA->getSetting($key));
}
+ $aliases = $userA->aliases()->orderBy('alias')->get();
+ $this->assertCount(2, $aliases);
+ $this->assertSame('useralias1@' . \config('app.domain'), $aliases[0]->alias);
+ $this->assertSame('useralias2@' . \config('app.domain'), $aliases[1]->alias);
// Test unsetting values
$post = [
@@ -308,6 +432,7 @@
'billing_address' => '',
'country' => '',
'currency' => '',
+ 'aliases' => ['useralias2@' . \config('app.domain')]
];
$response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post);
@@ -318,11 +443,95 @@
$this->assertSame('success', $json['status']);
$this->assertSame("User data updated successfully", $json['message']);
$this->assertCount(2, $json);
+ unset($post['aliases']);
foreach ($post as $key => $value) {
$this->assertNull($userA->getSetting($key));
}
+ $aliases = $userA->aliases()->get();
+ $this->assertCount(1, $aliases);
+ $this->assertSame('useralias2@' . \config('app.domain'), $aliases[0]->alias);
+
+ // Test error on setting an alias to other user's domain
+ // and missing password confirmation
+ $post = [
+ 'password' => 'simple123',
+ 'aliases' => ['useralias2@' . \config('app.domain'), 'useralias1@kolab.org']
+ ];
+
+ $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post);
+ $json = $response->json();
+
+ $response->assertStatus(422);
+
+ $this->assertSame('error', $json['status']);
+ $this->assertCount(2, $json['errors']);
+ $this->assertCount(1, $json['errors']['aliases']);
+ $this->assertSame("The specified domain is not available.", $json['errors']['aliases'][1]);
+ $this->assertSame("The password confirmation does not match.", $json['errors']['password'][0]);
+ // TODO: Test error on aliases with invalid/non-existing/other-user's domain
// TODO: Test authorized update of other user
$this->markTestIncomplete();
}
+
+ /**
+ * List of alias validation cases for testValidateEmail()
+ *
+ * @return array Arguments for testValidateEmail()
+ */
+ public function dataValidateEmail(): array
+ {
+ $this->refreshApplication();
+ $public_domains = Domain::getPublicDomains();
+ $domain = reset($public_domains);
+
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
+
+ return [
+ // Invalid format
+ ["$domain", $john, true, 'The specified alias is invalid.'],
+ [".@$domain", $john, true, 'The specified alias is invalid.'],
+ ["test123456@localhost", $john, true, 'The specified domain is invalid.'],
+ ["test123456@unknown-domain.org", $john, true, 'The specified domain is invalid.'],
+
+ ["$domain", $john, false, 'The specified email is invalid.'],
+ [".@$domain", $john, false, 'The specified email is invalid.'],
+
+ // forbidden local part on public domains
+ ["admin@$domain", $john, true, 'The specified alias is not available.'],
+ ["administrator@$domain", $john, true, 'The specified alias is not available.'],
+
+ // forbidden (other user's domain)
+ ["testtest@kolab.org", $user, true, 'The specified domain is not available.'],
+
+ // existing alias of other user
+ ["jack.daniels@kolab.org", $john, true, 'The specified alias is not available.'],
+
+ // existing user
+ ["jack@kolab.org", $john, true, 'The specified alias is not available.'],
+
+ // valid (user domain)
+ ["admin@kolab.org", $john, true, null],
+
+ // valid (public domain)
+ ["test.test@$domain", $john, true, null],
+ ];
+ }
+
+ /**
+ * User email/alias validation.
+ *
+ * Note: Technically these include unit tests, but let's keep it here for now.
+ * FIXME: Shall we do a http request for each case?
+ *
+ * @dataProvider dataValidateEmail
+ */
+ public function testValidateEmail($alias, $user, $is_alias, $expected_result): void
+ {
+ $result = $this->invokeMethod(new UsersController(), 'validateEmail', [$alias, $user, $is_alias]);
+
+ $this->assertSame($expected_result, $result);
+ }
}
diff --git a/src/tests/Feature/UserTest.php b/src/tests/Feature/UserTest.php
--- a/src/tests/Feature/UserTest.php
+++ b/src/tests/Feature/UserTest.php
@@ -29,6 +29,22 @@
}
/**
+ * Tests for User::assignPackage()
+ */
+ public function testAssignPackage(): void
+ {
+ $this->markTestIncomplete();
+ }
+
+ /**
+ * Tests for User::assignPlan()
+ */
+ public function testAssignPlan(): void
+ {
+ $this->markTestIncomplete();
+ }
+
+ /**
* Verify user creation process
*/
public function testUserCreateJob(): void
@@ -87,7 +103,24 @@
$this->assertTrue($userB->accounts()->get()[0]->id === $userA->wallets()->get()[0]->id);
}
- public function testUserDomains(): void
+ /**
+ * Tests for User::controller()
+ */
+ public function testController(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+
+ $this->assertSame($john->id, $john->controller()->id);
+
+ $jack = $this->getTestUser('jack@kolab.org');
+
+ $this->assertSame($john->id, $jack->controller()->id);
+ }
+
+ /**
+ * Tests for User::domains()
+ */
+ public function testDomains(): void
{
$user = $this->getTestUser('john@kolab.org');
$domains = [];
@@ -96,8 +129,20 @@
$domains[] = $domain->namespace;
}
- $this->assertContains('kolabnow.com', $domains);
+ $this->assertContains(\config('app.domain'), $domains);
$this->assertContains('kolab.org', $domains);
+
+ // Jack is not the wallet controller, so for him the list should not
+ // include John's domains, kolab.org specifically
+ $user = $this->getTestUser('jack@kolab.org');
+ $domains = [];
+
+ foreach ($user->domains() as $domain) {
+ $domains[] = $domain->namespace;
+ }
+
+ $this->assertContains(\config('app.domain'), $domains);
+ $this->assertNotContains('kolab.org', $domains);
}
public function testUserQuota(): void
@@ -158,7 +203,61 @@
$this->assertInstanceOf(User::class, $result);
$this->assertSame($user->id, $result->id);
- // TODO: Make sure searching is case-insensitive
- // TODO: Alias, eternal email
+ // Use an alias
+ $result = User::findByEmail('john.doe@kolab.org');
+ $this->assertInstanceOf(User::class, $result);
+ $this->assertSame($user->id, $result->id);
+
+ // TODO: searching by external email (setting)
+ $this->markTestIncomplete();
+ }
+
+ /**
+ * Tests for UserAliasesTrait::setAliases()
+ */
+ public function testSetAliases(): void
+ {
+ Queue::fake();
+
+ $user = $this->getTestUser('UserAccountA@UserAccount.com');
+
+ $this->assertCount(0, $user->aliases->all());
+
+ // Add an alias
+ $user->setAliases(['UserAlias1@UserAccount.com']);
+
+ $aliases = $user->aliases()->get();
+ $this->assertCount(1, $aliases);
+ $this->assertSame('useralias1@useraccount.com', $aliases[0]['alias']);
+
+ // Add another alias
+ $user->setAliases(['UserAlias1@UserAccount.com', 'UserAlias2@UserAccount.com']);
+
+ $aliases = $user->aliases()->orderBy('alias')->get();
+ $this->assertCount(2, $aliases);
+ $this->assertSame('useralias1@useraccount.com', $aliases[0]->alias);
+ $this->assertSame('useralias2@useraccount.com', $aliases[1]->alias);
+
+ // Remove an alias
+ $user->setAliases(['UserAlias1@UserAccount.com']);
+
+ $aliases = $user->aliases()->get();
+ $this->assertCount(1, $aliases);
+ $this->assertSame('useralias1@useraccount.com', $aliases[0]['alias']);
+
+ // Remove all aliases
+ $user->setAliases([]);
+
+ $this->assertCount(0, $user->aliases()->get());
+
+ // TODO: Test that the changes are propagated to ldap
+ }
+
+ /**
+ * Tests for UserSettingsTrait::setSettings()
+ */
+ public function testSetSettings(): void
+ {
+ $this->markTestIncomplete();
}
}
diff --git a/src/tests/TestCase.php b/src/tests/TestCase.php
--- a/src/tests/TestCase.php
+++ b/src/tests/TestCase.php
@@ -73,4 +73,22 @@
return $property->getValue($object);
}
+
+ /**
+ * Call protected/private method of a class.
+ *
+ * @param object $object Instantiated object that we will run method on.
+ * @param string $methodName Method name to call
+ * @param array $parameters Array of parameters to pass into method.
+ *
+ * @return mixed Method return.
+ */
+ public function invokeMethod($object, $methodName, array $parameters = array())
+ {
+ $reflection = new \ReflectionClass(get_class($object));
+ $method = $reflection->getMethod($methodName);
+ $method->setAccessible(true);
+
+ return $method->invokeArgs($object, $parameters);
+ }
}
diff --git a/src/tests/Unit/Rules/ExternalEmailTest.php b/src/tests/Unit/Rules/ExternalEmailTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Unit/Rules/ExternalEmailTest.php
@@ -0,0 +1,52 @@
+<?php
+
+namespace Tests\Unit\Rules;
+
+use App\Rules\ExternalEmail;
+use Illuminate\Support\Facades\Validator;
+use Tests\TestCase;
+
+class ExternalEmailTest extends TestCase
+{
+ /**
+ * List of email address validation cases for testExternalEmail()
+ *
+ * @return array Arguments for testExternalEmail()
+ */
+ public function dataExternalEmail(): array
+ {
+ return [
+ // invalid
+ ['example.org', 'The specified email address is invalid.'],
+ ['@example.org', 'The specified email address is invalid.'],
+ ['test@localhost', 'The specified email address is invalid.'],
+ // FIXME: empty - valid ??????
+ ['', null],
+ // valid
+ ['test@domain.tld', null],
+ ['&@example.org', null],
+ ];
+ }
+
+ /**
+ * Test external email validation
+ *
+ * @dataProvider dataExternalEmail
+ */
+ public function testExternalEmail($email, $expected_result): void
+ {
+ // Instead of doing direct tests, we use validator to make sure
+ // it works with the framework api
+ $v = Validator::make(
+ ['email' => $email],
+ ['email' => [new ExternalEmail()]]
+ );
+
+ $result = null;
+ if ($v->fails()) {
+ $result = $v->errors()->toArray()['email'][0];
+ }
+
+ $this->assertSame($expected_result, $result);
+ }
+}
diff --git a/src/tests/Unit/Rules/UserEmailDomainTest.php b/src/tests/Unit/Rules/UserEmailDomainTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Unit/Rules/UserEmailDomainTest.php
@@ -0,0 +1,18 @@
+<?php
+
+namespace Tests\Unit\Rules;
+
+use App\Rules\UserEmailDomain;
+use Illuminate\Support\Facades\Validator;
+use Tests\TestCase;
+
+class UserEmailDomainTest extends TestCase
+{
+ /**
+ * Test validation of email domain
+ */
+ public function testUserEmailDomain(): void
+ {
+ $this->markTestIncomplete();
+ }
+}
diff --git a/src/tests/Unit/Rules/UserEmailLocalTest.php b/src/tests/Unit/Rules/UserEmailLocalTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Unit/Rules/UserEmailLocalTest.php
@@ -0,0 +1,18 @@
+<?php
+
+namespace Tests\Unit\Rules;
+
+use App\Rules\UserEmailLocal;
+use Illuminate\Support\Facades\Validator;
+use Tests\TestCase;
+
+class UserEmailLocalTest extends TestCase
+{
+ /**
+ * Test validation of email local part
+ */
+ public function testUserEmailLocal(): void
+ {
+ $this->markTestIncomplete();
+ }
+}
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Fri, Apr 3, 1:23 PM (2 d, 5 h ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18769536
Default Alt Text
D976.1775222638.diff (92 KB)
Attached To
Mode
D976: Group: Additional user (Bifrost#T249344)
Attached
Detach File
Event Timeline