diff --git a/src/.env.example b/src/.env.example index cadae2fd..c8b9aced 100644 --- a/src/.env.example +++ b/src/.env.example @@ -1,167 +1,170 @@ APP_NAME=Kolab APP_ENV=local APP_KEY= APP_DEBUG=true APP_URL=http://127.0.0.1:8000 #APP_PASSPHRASE= APP_PUBLIC_URL= APP_DOMAIN=kolabnow.com APP_WEBSITE_DOMAIN=kolabnow.com APP_THEME=default APP_TENANT_ID=5 APP_LOCALE=en APP_LOCALES= APP_WITH_ADMIN=1 APP_WITH_RESELLER=1 APP_WITH_SERVICES=1 +SIGNUP_LIMIT_EMAIL=0 +SIGNUP_LIMIT_IP=0 + ASSET_URL=http://127.0.0.1:8000 WEBMAIL_URL=/apps SUPPORT_URL=/support SUPPORT_EMAIL= LOG_CHANNEL=stack LOG_SLOW_REQUESTS=5 DB_CONNECTION=mysql DB_DATABASE=kolabdev DB_HOST=127.0.0.1 DB_PASSWORD=kolab DB_PORT=3306 DB_USERNAME=kolabdev BROADCAST_DRIVER=redis CACHE_DRIVER=redis QUEUE_CONNECTION=redis SESSION_DRIVER=file SESSION_LIFETIME=120 OPENEXCHANGERATES_API_KEY="from openexchangerates.org" MFA_DSN=mysql://roundcube:Welcome2KolabSystems@127.0.0.1/roundcube MFA_TOTP_DIGITS=6 MFA_TOTP_INTERVAL=30 MFA_TOTP_DIGEST=sha1 IMAP_URI=ssl://127.0.0.1:11993 IMAP_ADMIN_LOGIN=cyrus-admin IMAP_ADMIN_PASSWORD=Welcome2KolabSystems IMAP_VERIFY_HOST=false IMAP_VERIFY_PEER=false LDAP_BASE_DN="dc=mgmt,dc=com" LDAP_DOMAIN_BASE_DN="ou=Domains,dc=mgmt,dc=com" LDAP_HOSTS=127.0.0.1 LDAP_PORT=389 LDAP_SERVICE_BIND_DN="uid=kolab-service,ou=Special Users,dc=mgmt,dc=com" LDAP_SERVICE_BIND_PW="Welcome2KolabSystems" LDAP_USE_SSL=false LDAP_USE_TLS=false # Administrative LDAP_ADMIN_BIND_DN="cn=Directory Manager" LDAP_ADMIN_BIND_PW="Welcome2KolabSystems" LDAP_ADMIN_ROOT_DN="dc=mgmt,dc=com" # Hosted (public registration) LDAP_HOSTED_BIND_DN="uid=hosted-kolab-service,ou=Special Users,dc=mgmt,dc=com" LDAP_HOSTED_BIND_PW="Welcome2KolabSystems" LDAP_HOSTED_ROOT_DN="dc=hosted,dc=com" OPENVIDU_API_PASSWORD=MY_SECRET OPENVIDU_API_URL=http://localhost:8080/api/ OPENVIDU_API_USERNAME=OPENVIDUAPP OPENVIDU_API_VERIFY_TLS=true OPENVIDU_COTURN_IP=127.0.0.1 OPENVIDU_COTURN_REDIS_DATABASE=2 OPENVIDU_COTURN_REDIS_IP=127.0.0.1 OPENVIDU_COTURN_REDIS_PASSWORD=turn # Used as COTURN_IP, TURN_PUBLIC_IP, for KMS_TURN_URL OPENVIDU_PUBLIC_IP=127.0.0.1 OPENVIDU_PUBLIC_PORT=3478 OPENVIDU_SERVER_PORT=8080 OPENVIDU_WEBHOOK=true OPENVIDU_WEBHOOK_ENDPOINT=http://127.0.0.1:8000/webhooks/meet/openvidu # "CDR" events, see https://docs.openvidu.io/en/2.13.0/reference-docs/openvidu-server-cdr/ #OPENVIDU_WEBHOOK_EVENTS=[sessionCreated,sessionDestroyed,participantJoined,participantLeft,webrtcConnectionCreated,webrtcConnectionDestroyed,recordingStatusChanged,filterEventDispatched,mediaNodeStatusChanged] #OPENVIDU_WEBHOOK_HEADERS=[\"Authorization:\ Basic\ SOMETHING\"] PGP_ENABLED= PGP_BINARY= PGP_AGENT= PGP_GPGCONF= PGP_LENGTH= REDIS_HOST=127.0.0.1 REDIS_PASSWORD=null REDIS_PORT=6379 SWOOLE_HOT_RELOAD_ENABLE=true SWOOLE_HTTP_ACCESS_LOG=true SWOOLE_HTTP_HOST=127.0.0.1 SWOOLE_HTTP_PORT=8000 SWOOLE_HTTP_REACTOR_NUM=1 SWOOLE_HTTP_WEBSOCKET=true SWOOLE_HTTP_WORKER_NUM=1 SWOOLE_OB_OUTPUT=true PAYMENT_PROVIDER= MOLLIE_KEY= STRIPE_KEY= STRIPE_PUBLIC_KEY= STRIPE_WEBHOOK_SECRET= MAIL_DRIVER=smtp MAIL_HOST=smtp.mailtrap.io MAIL_PORT=2525 MAIL_USERNAME=null MAIL_PASSWORD=null MAIL_ENCRYPTION=null MAIL_FROM_ADDRESS="noreply@example.com" MAIL_FROM_NAME="Example.com" MAIL_REPLYTO_ADDRESS="replyto@example.com" MAIL_REPLYTO_NAME=null DNS_TTL=3600 DNS_SPF="v=spf1 mx -all" DNS_STATIC="%s. MX 10 ext-mx01.mykolab.com." DNS_COPY_FROM=null AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= AWS_DEFAULT_REGION=us-east-1 AWS_BUCKET= PUSHER_APP_ID= PUSHER_APP_KEY= PUSHER_APP_SECRET= PUSHER_APP_CLUSTER=mt1 MIX_ASSET_PATH='/' MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}" MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" # Generate with ./artisan passport:client --password #PASSPORT_PROXY_OAUTH_CLIENT_ID= #PASSPORT_PROXY_OAUTH_CLIENT_SECRET= PASSPORT_PRIVATE_KEY= PASSPORT_PUBLIC_KEY= COMPANY_NAME= COMPANY_ADDRESS= COMPANY_DETAILS= COMPANY_EMAIL= COMPANY_LOGO= COMPANY_FOOTER= VAT_COUNTRIES=CH,LI VAT_RATE=7.7 KB_ACCOUNT_DELETE= KB_ACCOUNT_SUSPENDED= diff --git a/src/app/Http/Controllers/API/SignupController.php b/src/app/Http/Controllers/API/SignupController.php index 0983f03c..8a84238f 100644 --- a/src/app/Http/Controllers/API/SignupController.php +++ b/src/app/Http/Controllers/API/SignupController.php @@ -1,448 +1,448 @@ <?php namespace App\Http\Controllers\API; use App\Http\Controllers\Controller; use App\Jobs\SignupVerificationEmail; use App\Jobs\SignupVerificationSMS; use App\Discount; use App\Domain; use App\Plan; -use App\Rules\ExternalEmail; +use App\Rules\SignupExternalEmail; use App\Rules\UserEmailDomain; use App\Rules\UserEmailLocal; use App\SignupCode; use App\SignupInvitation; use App\User; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Validator; use Illuminate\Support\Str; /** * Signup process API */ class SignupController extends Controller { /** @var ?\App\SignupCode A verification code object */ protected $code; /** @var ?\App\Plan Signup plan object */ protected $plan; /** * Returns plans definitions for signup. * * @param \Illuminate\Http\Request $request HTTP request * * @return \Illuminate\Http\JsonResponse JSON response */ public function plans(Request $request) { $plans = []; // Use reverse order just to have individual on left, group on right ;) Plan::withEnvTenantContext()->orderByDesc('title')->get() ->map(function ($plan) use (&$plans) { $plans[] = [ 'title' => $plan->title, 'name' => $plan->name, 'button' => \trans('app.planbutton', ['plan' => $plan->name]), 'description' => $plan->description, ]; }); return response()->json(['status' => 'success', 'plans' => $plans]); } /** * Starts signup process. * * Verifies user name and email/phone, sends verification email/sms message. * Returns the verification code. * * @param \Illuminate\Http\Request $request HTTP request * * @return \Illuminate\Http\JsonResponse JSON response */ public function init(Request $request) { // Check required fields $v = Validator::make( $request->all(), [ 'email' => 'required', 'first_name' => 'max:128', 'last_name' => 'max:128', 'plan' => 'nullable|alpha_num|max:128', 'voucher' => 'max:32', ] ); $is_phone = false; $errors = $v->fails() ? $v->errors()->toArray() : []; // Validate user email (or phone) if (empty($errors['email'])) { if ($error = $this->validatePhoneOrEmail($request->email, $is_phone)) { $errors['email'] = $error; } } if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } // Generate the verification code $code = SignupCode::create([ 'email' => $request->email, 'first_name' => $request->first_name, 'last_name' => $request->last_name, 'plan' => $request->plan, 'voucher' => $request->voucher, ]); // Send email/sms message if ($is_phone) { SignupVerificationSMS::dispatch($code); } else { SignupVerificationEmail::dispatch($code); } return response()->json(['status' => 'success', 'code' => $code->code]); } /** * Returns signup invitation information. * * @param string $id Signup invitation identifier * * @return \Illuminate\Http\JsonResponse|void */ public function invitation($id) { $invitation = SignupInvitation::withEnvTenantContext()->find($id); if (empty($invitation) || $invitation->isCompleted()) { return $this->errorResponse(404); } $has_domain = $this->getPlan()->hasDomain(); $result = [ 'id' => $id, 'is_domain' => $has_domain, 'domains' => $has_domain ? [] : Domain::getPublicDomains(), ]; return response()->json($result); } /** * Validation of the verification code. * * @param \Illuminate\Http\Request $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) || $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; $has_domain = $this->getPlan()->hasDomain(); // Return user name and email/phone/voucher from the codes database, // domains list for selection and "plan type" flag return response()->json([ 'status' => 'success', 'email' => $code->email, 'first_name' => $code->first_name, 'last_name' => $code->last_name, 'voucher' => $code->voucher, 'is_domain' => $has_domain, 'domains' => $has_domain ? [] : Domain::getPublicDomains(), ]); } /** * Finishes the signup process by creating the user account. * * @param \Illuminate\Http\Request $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', 'domain' => 'required', 'voucher' => 'max:32', ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } // Signup via invitation if ($request->invitation) { $invitation = SignupInvitation::withEnvTenantContext()->find($request->invitation); if (empty($invitation) || $invitation->isCompleted()) { return $this->errorResponse(404); } // Check required fields $v = Validator::make( $request->all(), [ 'first_name' => 'max:128', 'last_name' => 'max:128', 'voucher' => 'max:32', ] ); $errors = $v->fails() ? $v->errors()->toArray() : []; if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } $settings = [ 'external_email' => $invitation->email, 'first_name' => $request->first_name, 'last_name' => $request->last_name, ]; } else { // 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(); $settings = [ 'external_email' => $code_data->email, 'first_name' => $code_data->first_name, 'last_name' => $code_data->last_name, ]; } // Find the voucher discount if ($request->voucher) { $discount = Discount::where('code', \strtoupper($request->voucher)) ->where('active', true)->first(); if (!$discount) { $errors = ['voucher' => \trans('validation.voucherinvalid')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } } // Get the plan $plan = $this->getPlan(); $is_domain = $plan->hasDomain(); $login = $request->login; $domain_name = $request->domain; // Validate login if ($errors = self::validateLogin($login, $domain_name, $is_domain)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } // We allow only ASCII, so we can safely lower-case the email address $login = Str::lower($login); $domain_name = Str::lower($domain_name); $domain = null; DB::beginTransaction(); // Create domain record if ($is_domain) { $domain = Domain::create([ 'namespace' => $domain_name, 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); } // Create user record $user = User::create([ 'email' => $login . '@' . $domain_name, 'password' => $request->password, ]); if (!empty($discount)) { $wallet = $user->wallets()->first(); $wallet->discount()->associate($discount); $wallet->save(); } $user->assignPlan($plan, $domain); // Save the external email and plan in user settings $user->setSettings($settings); // Update the invitation if (!empty($invitation)) { $invitation->status = SignupInvitation::STATUS_COMPLETED; $invitation->user_id = $user->id; $invitation->save(); } // Remove the verification code if ($this->code) { $this->code->delete(); } DB::commit(); return AuthController::logonResponse($user, $request->password); } /** * 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->plan) { $plan = Plan::withEnvTenantContext()->where('title', $this->code->plan)->first(); } // ...otherwise use the default plan if (empty($plan)) { // TODO: Get default plan title from config $plan = Plan::withEnvTenantContext()->where('title', 'individual')->first(); } $this->plan = $plan; } return $this->plan; } /** * 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 * * @return string Error message on validation error */ protected static function validatePhoneOrEmail($input, &$is_phone = false): ?string { $is_phone = false; $v = Validator::make( ['email' => $input], - ['email' => ['required', 'string', new ExternalEmail()]] + ['email' => ['required', 'string', new SignupExternalEmail()]] ); if ($v->fails()) { return $v->errors()->toArray()['email'][0]; } // TODO: Phone number support /* $input = str_replace(array('-', ' '), '', $input); if (!preg_match('/^\+?[0-9]{9,12}$/', $input)) { return \trans('validation.noemailorphone'); } $is_phone = true; */ return null; } /** * Login (kolab identity) validation * * @param string $login Login (local part of an email address) * @param string $domain Domain name * @param bool $external Enables additional checks for domain part * * @return array Error messages on validation error */ protected static function validateLogin($login, $domain, $external = false): ?array { // Validate login part alone $v = Validator::make( ['login' => $login], ['login' => ['required', 'string', new UserEmailLocal($external)]] ); if ($v->fails()) { return ['login' => $v->errors()->toArray()['login'][0]]; } $domains = $external ? null : Domain::getPublicDomains(); // Validate the domain $v = Validator::make( ['domain' => $domain], ['domain' => ['required', 'string', new UserEmailDomain($domains)]] ); if ($v->fails()) { return ['domain' => $v->errors()->toArray()['domain'][0]]; } $domain = Str::lower($domain); // Check if domain is already registered with us if ($external) { if (Domain::where('namespace', $domain)->first()) { return ['domain' => \trans('validation.domainexists')]; } } // Check if user with specified login already exists $email = $login . '@' . $domain; if (User::emailExists($email) || User::aliasExists($email) || \App\Group::emailExists($email)) { return ['login' => \trans('validation.loginexists')]; } return null; } } diff --git a/src/app/Rules/ExternalEmail.php b/src/app/Rules/ExternalEmail.php index de112d2a..b0ee7a24 100644 --- a/src/app/Rules/ExternalEmail.php +++ b/src/app/Rules/ExternalEmail.php @@ -1,52 +1,52 @@ <?php namespace App\Rules; use Illuminate\Contracts\Validation\Rule; use Illuminate\Support\Facades\Validator; class ExternalEmail implements Rule { - private $message; + protected $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/SignupExternalEmail.php b/src/app/Rules/SignupExternalEmail.php new file mode 100644 index 00000000..957dec6c --- /dev/null +++ b/src/app/Rules/SignupExternalEmail.php @@ -0,0 +1,50 @@ +<?php + +namespace App\Rules; + +use App\SignupCode; + +class SignupExternalEmail extends ExternalEmail +{ + /** + * {@inheritDoc} + */ + public function passes($attribute, $email): bool + { + if (!parent::passes($attribute, $email)) { + return false; + } + + // Check the max length, according to the database column length + if (strlen($email) > 191) { + $this->message = \trans('validation.emailinvalid'); + return false; + } + + // Don't allow multiple open registrations against the same email address + if (($limit = \config('app.signup.email_limit')) > 0) { + $signups = SignupCode::where('email', $email) + ->whereDate('expires_at', '>', \Carbon\Carbon::now()); + + if ($signups->count() >= $limit) { + // @kanarip: this is deliberately an "email invalid" message + $this->message = \trans('validation.emailinvalid'); + return false; + } + } + + // Don't allow multiple open registrations against the same source ip address + if (($limit = \config('app.signup.ip_limit')) > 0) { + $signups = SignupCode::where('ip_address', request()->ip()) + ->whereDate('expires_at', '>', \Carbon\Carbon::now()); + + if ($signups->count() >= $limit) { + // @kanarip: this is deliberately an "email invalid" message + $this->message = \trans('validation.emailinvalid'); + return false; + } + } + + return true; + } +} diff --git a/src/config/app.php b/src/config/app.php index 41755949..ed3c5ab6 100644 --- a/src/config/app.php +++ b/src/config/app.php @@ -1,291 +1,296 @@ <?php return [ /* |-------------------------------------------------------------------------- | Application Name |-------------------------------------------------------------------------- | | This value is the name of your application. This value is used when the | framework needs to place the application's name in a notification or | any other location as required by the application or its packages. | */ 'name' => env('APP_NAME', 'Laravel'), /* |-------------------------------------------------------------------------- | Application Environment |-------------------------------------------------------------------------- | | This value determines the "environment" your application is currently | running in. This may determine how you prefer to configure various | services the application utilizes. Set this in your ".env" file. | */ 'env' => env('APP_ENV', 'production'), /* |-------------------------------------------------------------------------- | Application Debug Mode |-------------------------------------------------------------------------- | | When your application is in debug mode, detailed error messages with | stack traces will be shown on every error that occurs within your | application. If disabled, a simple generic error page is shown. | */ 'debug' => env('APP_DEBUG', false), /* |-------------------------------------------------------------------------- | Application URL |-------------------------------------------------------------------------- | | This URL is used by the console to properly generate URLs when using | the Artisan command line tool. You should set this to the root of | your application so that it is used when running Artisan tasks. */ 'url' => env('APP_URL', 'http://localhost'), 'passphrase' => env('APP_PASSPHRASE', null), 'public_url' => env('APP_PUBLIC_URL', env('APP_URL', 'http://localhost')), 'asset_url' => env('ASSET_URL', null), 'support_url' => env('SUPPORT_URL', null), 'support_email' => env('SUPPORT_EMAIL', null), 'webmail_url' => env('WEBMAIL_URL', null), 'theme' => env('APP_THEME', 'default'), 'tenant_id' => env('APP_TENANT_ID', null), 'currency' => \strtoupper(env('APP_CURRENCY', 'CHF')), /* |-------------------------------------------------------------------------- | Application Domain |-------------------------------------------------------------------------- | | System domain used for user signup (kolab identity) */ 'domain' => env('APP_DOMAIN', 'domain.tld'), 'website_domain' => env('APP_WEBSITE_DOMAIN', env('APP_DOMAIN', 'domain.tld')), /* |-------------------------------------------------------------------------- | Application Timezone |-------------------------------------------------------------------------- | | Here you may specify the default timezone for your application, which | will be used by the PHP date and date-time functions. We have gone | ahead and set this to a sensible default for you out of the box. | */ 'timezone' => 'UTC', /* |-------------------------------------------------------------------------- | Application Locale Configuration |-------------------------------------------------------------------------- | | The application locale determines the default locale that will be used | by the translation service provider. You are free to set this value | to any of the locales which will be supported by the application. | */ 'locale' => env('APP_LOCALE', 'en'), /* |-------------------------------------------------------------------------- | Application Fallback Locale |-------------------------------------------------------------------------- | | The fallback locale determines the locale to use when the current one | is not available. You may change the value to correspond to any of | the language folders that are provided through your application. | */ 'fallback_locale' => 'en', /* |-------------------------------------------------------------------------- | Faker Locale |-------------------------------------------------------------------------- | | This locale will be used by the Faker PHP library when generating fake | data for your database seeds. For example, this will be used to get | localized telephone numbers, street address information and more. | */ 'faker_locale' => 'en_US', /* |-------------------------------------------------------------------------- | Encryption Key |-------------------------------------------------------------------------- | | This key is used by the Illuminate encrypter service and should be set | to a random, 32 character string, otherwise these encrypted strings | will not be safe. Please do this before deploying an application! | */ 'key' => env('APP_KEY'), 'cipher' => 'AES-256-CBC', /* |-------------------------------------------------------------------------- | Autoloaded Service Providers |-------------------------------------------------------------------------- | | The service providers listed here will be automatically loaded on the | request to your application. Feel free to add your own services to | this array to grant expanded functionality to your applications. | */ 'providers' => [ /* * Laravel Framework Service Providers... */ Illuminate\Auth\AuthServiceProvider::class, Illuminate\Broadcasting\BroadcastServiceProvider::class, Illuminate\Bus\BusServiceProvider::class, Illuminate\Cache\CacheServiceProvider::class, Illuminate\Foundation\Providers\ConsoleSupportServiceProvider::class, Illuminate\Cookie\CookieServiceProvider::class, Illuminate\Database\DatabaseServiceProvider::class, Illuminate\Encryption\EncryptionServiceProvider::class, Illuminate\Filesystem\FilesystemServiceProvider::class, Illuminate\Foundation\Providers\FoundationServiceProvider::class, Illuminate\Hashing\HashServiceProvider::class, Illuminate\Mail\MailServiceProvider::class, Illuminate\Notifications\NotificationServiceProvider::class, Illuminate\Pagination\PaginationServiceProvider::class, Illuminate\Pipeline\PipelineServiceProvider::class, Illuminate\Queue\QueueServiceProvider::class, Illuminate\Redis\RedisServiceProvider::class, Illuminate\Auth\Passwords\PasswordResetServiceProvider::class, Illuminate\Session\SessionServiceProvider::class, Illuminate\Translation\TranslationServiceProvider::class, Illuminate\Validation\ValidationServiceProvider::class, Illuminate\View\ViewServiceProvider::class, /* * Package Service Providers... */ Barryvdh\DomPDF\ServiceProvider::class, /* * Application Service Providers... */ App\Providers\AppServiceProvider::class, App\Providers\AuthServiceProvider::class, // App\Providers\BroadcastServiceProvider::class, App\Providers\EventServiceProvider::class, App\Providers\HorizonServiceProvider::class, App\Providers\PassportServiceProvider::class, App\Providers\RouteServiceProvider::class, ], /* |-------------------------------------------------------------------------- | Class Aliases |-------------------------------------------------------------------------- | | This array of class aliases will be registered when this application | is started. However, feel free to register as many as you wish as | the aliases are "lazy" loaded so they don't hinder performance. | */ 'aliases' => [ 'App' => Illuminate\Support\Facades\App::class, 'Arr' => Illuminate\Support\Arr::class, 'Artisan' => Illuminate\Support\Facades\Artisan::class, 'Auth' => Illuminate\Support\Facades\Auth::class, 'Blade' => Illuminate\Support\Facades\Blade::class, 'Broadcast' => Illuminate\Support\Facades\Broadcast::class, 'Bus' => Illuminate\Support\Facades\Bus::class, 'Cache' => Illuminate\Support\Facades\Cache::class, 'Config' => Illuminate\Support\Facades\Config::class, 'Cookie' => Illuminate\Support\Facades\Cookie::class, 'Crypt' => Illuminate\Support\Facades\Crypt::class, 'DB' => Illuminate\Support\Facades\DB::class, 'Eloquent' => Illuminate\Database\Eloquent\Model::class, 'Event' => Illuminate\Support\Facades\Event::class, 'File' => Illuminate\Support\Facades\File::class, 'Gate' => Illuminate\Support\Facades\Gate::class, 'Hash' => Illuminate\Support\Facades\Hash::class, 'Lang' => Illuminate\Support\Facades\Lang::class, 'Log' => Illuminate\Support\Facades\Log::class, 'Mail' => Illuminate\Support\Facades\Mail::class, 'Notification' => Illuminate\Support\Facades\Notification::class, 'Password' => Illuminate\Support\Facades\Password::class, 'PDF' => Barryvdh\DomPDF\Facade::class, 'Queue' => Illuminate\Support\Facades\Queue::class, 'Redirect' => Illuminate\Support\Facades\Redirect::class, 'Redis' => Illuminate\Support\Facades\Redis::class, 'Request' => Illuminate\Support\Facades\Request::class, 'Response' => Illuminate\Support\Facades\Response::class, 'Route' => Illuminate\Support\Facades\Route::class, 'Schema' => Illuminate\Support\Facades\Schema::class, 'Session' => Illuminate\Support\Facades\Session::class, 'Storage' => Illuminate\Support\Facades\Storage::class, 'Str' => Illuminate\Support\Str::class, 'URL' => Illuminate\Support\Facades\URL::class, 'Validator' => Illuminate\Support\Facades\Validator::class, 'View' => Illuminate\Support\Facades\View::class, ], // Locations of knowledge base articles 'kb' => [ // An article about suspended accounts 'account_suspended' => env('KB_ACCOUNT_SUSPENDED'), // An article about a way to delete an owned account 'account_delete' => env('KB_ACCOUNT_DELETE'), ], 'company' => [ 'name' => env('COMPANY_NAME'), 'address' => env('COMPANY_ADDRESS'), 'details' => env('COMPANY_DETAILS'), 'email' => env('COMPANY_EMAIL'), 'logo' => env('COMPANY_LOGO'), 'footer' => env('COMPANY_FOOTER', env('COMPANY_DETAILS')), ], 'storage' => [ 'min_qty' => (int) env('STORAGE_MIN_QTY', 5), // in GB ], 'vat' => [ 'countries' => env('VAT_COUNTRIES'), 'rate' => (float) env('VAT_RATE'), ], 'payment' => [ 'methods_oneoff' => env('PAYMENT_METHODS_ONEOFF', "creditcard,paypal,banktransfer"), 'methods_recurring' => env('PAYMENT_METHODS_RECURRING', "creditcard"), ], 'with_admin' => (bool) env('APP_WITH_ADMIN', false), 'with_reseller' => (bool) env('APP_WITH_RESELLER', false), 'with_services' => (bool) env('APP_WITH_SERVICES', false), + + 'signup' => [ + 'email_limit' => (int) env('SIGNUP_LIMIT_EMAIL', 0), + 'ip_limit' => (int) env('SIGNUP_LIMIT_IP', 0), + ], ]; diff --git a/src/tests/Feature/Controller/SignupTest.php b/src/tests/Feature/Controller/SignupTest.php index b67ceb35..306070f9 100644 --- a/src/tests/Feature/Controller/SignupTest.php +++ b/src/tests/Feature/Controller/SignupTest.php @@ -1,782 +1,852 @@ <?php namespace Tests\Feature\Controller; use App\Http\Controllers\API\SignupController; use App\Discount; use App\Domain; use App\SignupCode; use App\SignupInvitation as SI; use App\User; use Illuminate\Support\Facades\Queue; use Tests\TestCase; class SignupTest extends TestCase { private $domain; /** * {@inheritDoc} */ public function setUp(): void { parent::setUp(); // TODO: Some tests depend on existence of individual and group plans, // we should probably create plans here to not depend on that $this->domain = $this->getPublicDomain(); $this->deleteTestUser("SignupControllerTest1@$this->domain"); $this->deleteTestUser("signuplogin@$this->domain"); $this->deleteTestUser("admin@external.com"); $this->deleteTestUser("test-inv@kolabnow.com"); $this->deleteTestDomain('external.com'); $this->deleteTestDomain('signup-domain.com'); $this->deleteTestGroup('group-test@kolabnow.com'); SI::truncate(); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser("SignupControllerTest1@$this->domain"); $this->deleteTestUser("signuplogin@$this->domain"); $this->deleteTestUser("admin@external.com"); $this->deleteTestUser("test-inv@kolabnow.com"); $this->deleteTestDomain('external.com'); $this->deleteTestDomain('signup-domain.com'); $this->deleteTestGroup('group-test@kolabnow.com'); SI::truncate(); parent::tearDown(); } /** * Return a public domain for signup tests */ private function getPublicDomain(): string { if (!$this->domain) { $this->refreshApplication(); $public_domains = Domain::getPublicDomains(); $this->domain = reset($public_domains); if (empty($this->domain)) { $this->domain = 'signup-domain.com'; Domain::create([ 'namespace' => $this->domain, 'status' => Domain::STATUS_ACTIVE, 'type' => Domain::TYPE_PUBLIC, ]); } } return $this->domain; } /** * Test fetching plans for signup */ public function testSignupPlans(): void { $response = $this->get('/api/auth/signup/plans'); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertCount(2, $json['plans']); $this->assertArrayHasKey('title', $json['plans'][0]); $this->assertArrayHasKey('name', $json['plans'][0]); $this->assertArrayHasKey('description', $json['plans'][0]); $this->assertArrayHasKey('button', $json['plans'][0]); } /** * Test fetching invitation */ public function testSignupInvitations(): void { Queue::fake(); $invitation = SI::create(['email' => 'email1@ext.com']); // Test existing invitation $response = $this->get("/api/auth/signup/invitations/{$invitation->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame($invitation->id, $json['id']); // Test non-existing invitation $response = $this->get("/api/auth/signup/invitations/abc"); $response->assertStatus(404); // Test completed invitation SI::where('id', $invitation->id)->update(['status' => SI::STATUS_COMPLETED]); $response = $this->get("/api/auth/signup/invitations/{$invitation->id}"); $response->assertStatus(404); } /** * Test signup initialization with invalid input */ public function testSignupInitInvalidInput(): void { // 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(1, $json['errors']); $this->assertArrayHasKey('email', $json['errors']); // Data with missing name $data = [ 'email' => 'UsersApiControllerTest1@UsersApiControllerTest.com', 'first_name' => str_repeat('a', 250), 'last_name' => str_repeat('a', 250), ]; $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('first_name', $json['errors']); $this->assertArrayHasKey('last_name', $json['errors']); // Data with invalid email (but not phone number) $data = [ 'email' => '@example.org', 'first_name' => 'Signup', 'last_name' => 'User', ]; $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']); // Sanity check on voucher code, last/first name is optional $data = [ 'voucher' => '123456789012345678901234567890123', 'email' => 'valid@email.com', ]; $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('voucher', $json['errors']); + // Email address too long + $data = [ + 'email' => str_repeat('a', 190) . '@example.org', + 'first_name' => 'Signup', + 'last_name' => 'User', + ]; + + $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->assertSame("The specified email address is invalid.", $json['errors']['email']); + + SignupCode::truncate(); + + // Email address limit check + $data = [ + 'email' => 'test@example.org', + 'first_name' => 'Signup', + 'last_name' => 'User', + ]; + + \config(['app.signup.email_limit' => 0]); + + $response = $this->post('/api/auth/signup/init', $data); + $json = $response->json(); + + $response->assertStatus(200); + + \config(['app.signup.email_limit' => 1]); + + $response = $this->post('/api/auth/signup/init', $data); + $json = $response->json(); + + $response->assertStatus(422); + $this->assertSame('error', $json['status']); + $this->assertCount(1, $json['errors']); + // TODO: This probably should be a different message? + $this->assertSame("The specified email address is invalid.", $json['errors']['email']); + + // IP address limit check + $data = [ + 'email' => 'ip@example.org', + 'first_name' => 'Signup', + 'last_name' => 'User', + ]; + + \config(['app.signup.email_limit' => 0]); + \config(['app.signup.ip_limit' => 0]); + + $response = $this->post('/api/auth/signup/init', $data, ['REMOTE_ADDR' => '10.1.1.1']); + $json = $response->json(); + + $response->assertStatus(200); + + \config(['app.signup.ip_limit' => 1]); + + $response = $this->post('/api/auth/signup/init', $data, ['REMOTE_ADDR' => '10.1.1.1']); + $json = $response->json(); + + $response->assertStatus(422); + + $this->assertSame('error', $json['status']); + $this->assertCount(1, $json['errors']); + // TODO: This probably should be a different message? + $this->assertSame("The specified email address is invalid.", $json['errors']['email']); + // TODO: Test phone validation } /** * Test signup initialization with valid input */ public function testSignupInitValidInput(): array { Queue::fake(); // Assert that no jobs were pushed... Queue::assertNothingPushed(); $data = [ 'email' => 'testuser@external.com', 'first_name' => 'Signup', 'last_name' => 'User', 'plan' => 'individual', ]; $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) { $code = TestCase::getObjectProperty($job, 'code'); return $code->code === $json['code'] && $code->plan === $data['plan'] && $code->email === $data['email'] && $code->first_name === $data['first_name'] && $code->last_name === $data['last_name']; }); // Try the same with voucher $data['voucher'] = 'TEST'; $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 job has proper data assigned Queue::assertPushed(\App\Jobs\SignupVerificationEmail::class, function ($job) use ($data, $json) { $code = TestCase::getObjectProperty($job, 'code'); return $code->code === $json['code'] && $code->plan === $data['plan'] && $code->email === $data['email'] && $code->voucher === $data['voucher'] && $code->first_name === $data['first_name'] && $code->last_name === $data['last_name']; }); return [ 'code' => $json['code'], 'email' => $data['email'], 'first_name' => $data['first_name'], 'last_name' => $data['last_name'], 'plan' => $data['plan'], 'voucher' => $data['voucher'] ]; } /** * Test signup code verification with invalid input * * @depends testSignupInitValidInput */ public function testSignupVerifyInvalidInput(array $result): void { // 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 */ public function testSignupVerifyValidInput(array $result): array { $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(7, $json); $this->assertSame('success', $json['status']); $this->assertSame($result['email'], $json['email']); $this->assertSame($result['first_name'], $json['first_name']); $this->assertSame($result['last_name'], $json['last_name']); $this->assertSame($result['voucher'], $json['voucher']); $this->assertSame(false, $json['is_domain']); $this->assertTrue(is_array($json['domains']) && !empty($json['domains'])); return $result; } /** * Test last signup step with invalid input * * @depends testSignupVerifyValidInput */ public function testSignupInvalidInput(array $result): void { // Empty data $data = []; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(3, $json['errors']); $this->assertArrayHasKey('login', $json['errors']); $this->assertArrayHasKey('password', $json['errors']); $this->assertArrayHasKey('domain', $json['errors']); $domain = $this->getPublicDomain(); // Passwords do not match and missing domain $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(2, $json['errors']); $this->assertArrayHasKey('password', $json['errors']); $this->assertArrayHasKey('domain', $json['errors']); $domain = $this->getPublicDomain(); // Login too short $data = [ 'login' => '1', 'domain' => $domain, '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']); // Missing codes $data = [ 'login' => 'login-valid', 'domain' => $domain, '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(2, $json['errors']); $this->assertArrayHasKey('code', $json['errors']); $this->assertArrayHasKey('short_code', $json['errors']); // Data with invalid short_code $data = [ 'login' => 'TestLogin', 'domain' => $domain, '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']); $code = SignupCode::find($result['code']); // Data with invalid voucher $data = [ 'login' => 'TestLogin', 'domain' => $domain, 'password' => 'test', 'password_confirmation' => 'test', 'code' => $result['code'], 'short_code' => $code->short_code, 'voucher' => 'XXX', ]; $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('voucher', $json['errors']); // Valid code, invalid login $data = [ 'login' => 'żżżżżż', 'domain' => $domain, 'password' => 'test', 'password_confirmation' => 'test', 'code' => $result['code'], 'short_code' => $code->short_code, ]; $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']); } /** * Test last signup step with valid input (user creation) * * @depends testSignupVerifyValidInput */ public function testSignupValidInput(array $result): void { $queue = Queue::fake(); $domain = $this->getPublicDomain(); $identity = \strtolower('SignupLogin@') . $domain; $code = SignupCode::find($result['code']); $data = [ 'login' => 'SignupLogin', 'domain' => $domain, 'password' => 'test', 'password_confirmation' => 'test', 'code' => $code->code, 'short_code' => $code->short_code, 'voucher' => 'TEST', ]; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(200); $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']); $this->assertSame($identity, $json['email']); Queue::assertPushed(\App\Jobs\User\CreateJob::class, 1); Queue::assertPushed( \App\Jobs\User\CreateJob::class, function ($job) use ($data) { $userEmail = TestCase::getObjectProperty($job, 'userEmail'); return $userEmail === \strtolower($data['login'] . '@' . $data['domain']); } ); // Check if the code has been removed $this->assertNull(SignupCode::where('code', $result['code'])->first()); // Check if the user has been created $user = User::where('email', $identity)->first(); $this->assertNotEmpty($user); $this->assertSame($identity, $user->email); // Check user settings $this->assertSame($result['first_name'], $user->getSetting('first_name')); $this->assertSame($result['last_name'], $user->getSetting('last_name')); $this->assertSame($result['email'], $user->getSetting('external_email')); // Discount $discount = Discount::where('code', 'TEST')->first(); $this->assertSame($discount->id, $user->wallets()->first()->discount_id); // TODO: Check SKUs/Plan // TODO: Check if the access token works } /** * Test signup for a group (custom domain) account */ public function testSignupGroupAccount(): void { Queue::fake(); // Initial signup request $user_data = $data = [ 'email' => 'testuser@external.com', 'first_name' => 'Signup', 'last_name' => 'User', 'plan' => 'group', ]; $response = $this->withoutMiddleware()->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) { $code = TestCase::getObjectProperty($job, 'code'); return $code->code === $json['code'] && $code->plan === $data['plan'] && $code->email === $data['email'] && $code->first_name === $data['first_name'] && $code->last_name === $data['last_name']; }); // Verify the code $code = SignupCode::find($json['code']); $data = [ 'code' => $code->code, 'short_code' => $code->short_code, ]; $response = $this->post('/api/auth/signup/verify', $data); $result = $response->json(); $response->assertStatus(200); $this->assertCount(7, $result); $this->assertSame('success', $result['status']); $this->assertSame($user_data['email'], $result['email']); $this->assertSame($user_data['first_name'], $result['first_name']); $this->assertSame($user_data['last_name'], $result['last_name']); $this->assertSame(null, $result['voucher']); $this->assertSame(true, $result['is_domain']); $this->assertSame([], $result['domains']); // Final signup request $login = 'admin'; $domain = 'external.com'; $data = [ 'login' => $login, 'domain' => $domain, 'password' => 'test', 'password_confirmation' => 'test', 'code' => $code->code, 'short_code' => $code->short_code, ]; $response = $this->post('/api/auth/signup', $data); $result = $response->json(); $response->assertStatus(200); $this->assertSame('success', $result['status']); $this->assertSame('bearer', $result['token_type']); $this->assertTrue(!empty($result['expires_in']) && is_int($result['expires_in']) && $result['expires_in'] > 0); $this->assertNotEmpty($result['access_token']); $this->assertSame("$login@$domain", $result['email']); Queue::assertPushed(\App\Jobs\Domain\CreateJob::class, 1); Queue::assertPushed( \App\Jobs\Domain\CreateJob::class, function ($job) use ($domain) { $domainNamespace = TestCase::getObjectProperty($job, 'domainNamespace'); return $domainNamespace === $domain; } ); Queue::assertPushed(\App\Jobs\User\CreateJob::class, 1); Queue::assertPushed( \App\Jobs\User\CreateJob::class, function ($job) use ($data) { $userEmail = TestCase::getObjectProperty($job, 'userEmail'); return $userEmail === $data['login'] . '@' . $data['domain']; } ); // Check if the code has been removed $this->assertNull(SignupCode::find($code->id)); // Check if the user has been created $user = User::where('email', $login . '@' . $domain)->first(); $this->assertNotEmpty($user); // Check user settings $this->assertSame($user_data['email'], $user->getSetting('external_email')); $this->assertSame($user_data['first_name'], $user->getSetting('first_name')); $this->assertSame($user_data['last_name'], $user->getSetting('last_name')); // TODO: Check domain record // TODO: Check SKUs/Plan // TODO: Check if the access token works } /** * Test signup via invitation */ public function testSignupViaInvitation(): void { Queue::fake(); $invitation = SI::create(['email' => 'email1@ext.com']); $post = [ 'invitation' => 'abc', 'first_name' => 'Signup', 'last_name' => 'User', 'login' => 'test-inv', 'domain' => 'kolabnow.com', 'password' => 'test', 'password_confirmation' => 'test', ]; // Test invalid invitation identifier $response = $this->post('/api/auth/signup', $post); $response->assertStatus(404); // Test valid input $post['invitation'] = $invitation->id; $response = $this->post('/api/auth/signup', $post); $result = $response->json(); $response->assertStatus(200); $this->assertSame('success', $result['status']); $this->assertSame('bearer', $result['token_type']); $this->assertTrue(!empty($result['expires_in']) && is_int($result['expires_in']) && $result['expires_in'] > 0); $this->assertNotEmpty($result['access_token']); $this->assertSame('test-inv@kolabnow.com', $result['email']); // Check if the user has been created $user = User::where('email', 'test-inv@kolabnow.com')->first(); $this->assertNotEmpty($user); // Check user settings $this->assertSame($invitation->email, $user->getSetting('external_email')); $this->assertSame($post['first_name'], $user->getSetting('first_name')); $this->assertSame($post['last_name'], $user->getSetting('last_name')); $invitation->refresh(); $this->assertSame($user->id, $invitation->user_id); $this->assertTrue($invitation->isCompleted()); // TODO: Test POST params validation } /** * List of login/domain validation cases for testValidateLogin() * * @return array Arguments for testValidateLogin() */ public function dataValidateLogin(): array { $domain = $this->getPublicDomain(); return [ // Individual account ['', $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' => '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.']], // 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' => 'The specified domain is invalid.']], ['testnonsystemdomain', '.com', true, ['domain' => 'The specified domain is invalid.']], // existing custom domain ['jack', 'kolab.org', true, ['domain' => 'The specified domain is not available.']], ]; } /** * Signup login/domain 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 dataValidateLogin */ public function testValidateLogin($login, $domain, $external, $expected_result): void { $result = $this->invokeMethod(new SignupController(), 'validateLogin', [$login, $domain, $external]); $this->assertSame($expected_result, $result); } /** * Signup login/domain validation, more cases * * Note: Technically these include unit tests, but let's keep it here for now. */ public function testValidateLoginMore(): void { $group = $this->getTestGroup('group-test@kolabnow.com'); $login = 'group-test'; $domain = 'kolabnow.com'; $external = false; $result = $this->invokeMethod(new SignupController(), 'validateLogin', [$login, $domain, $external]); $this->assertSame(['login' => 'The specified login is not available.'], $result); } }