diff --git a/src/app/Http/Controllers/API/SignupController.php b/src/app/Http/Controllers/API/SignupController.php index 7a6be7ef..b14ccdb3 100644 --- a/src/app/Http/Controllers/API/SignupController.php +++ b/src/app/Http/Controllers/API/SignupController.php @@ -1,369 +1,391 @@ map(function ($plan) use (&$plans) { + // TODO: Localization + $plans[] = [ + 'title' => $plan->title, + '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 HTTP request * * @return \Illuminate\Http\JsonResponse JSON response */ public function init(Request $request) { // Check required fields $v = Validator::make( $request->all(), [ 'email' => 'required', 'name' => 'required|max:512', 'plan' => 'nullable|alpha_num|max:128', ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } // Validate user email (or phone) if ($error = $this->validatePhoneOrEmail($request->email, $is_phone)) { return response()->json(['status' => 'error', 'errors' => ['email' => __($error)]], 422); } // Generate the verification code $code = SignupCode::create([ 'data' => [ 'email' => $request->email, 'name' => $request->name, 'plan' => $request->plan, ] ]); // Send email/sms message if ($is_phone) { SignupVerificationSMS::dispatch($code); } else { SignupVerificationEmail::dispatch($code); } return response()->json(['status' => 'success', 'code' => $code->code]); } /** * Validation of the verification code. * * @param Illuminate\Http\Request HTTP request * * @return \Illuminate\Http\JsonResponse JSON response */ public function verify(Request $request) { // Validate the request args $v = Validator::make( $request->all(), [ 'code' => 'required', 'short_code' => 'required', ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } // Validate the verification code $code = SignupCode::find($request->code); if ( empty($code) || $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 from the codes database, // domains list for selection and "plan type" flag return response()->json([ 'status' => 'success', 'email' => $code->data['email'], 'name' => $code->data['name'], 'is_domain' => $has_domain, 'domains' => $has_domain ? [] : Domain::getPublicDomains(), ]); } /** * Finishes the signup process by creating the user account. * * @param Illuminate\Http\Request HTTP request * * @return \Illuminate\Http\JsonResponse JSON response */ public function signup(Request $request) { // Validate input $v = Validator::make( $request->all(), [ 'login' => 'required|min:2', 'password' => 'required|min:4|confirmed', 'domain' => 'required', ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } // Validate verification codes (again) $v = $this->verify($request); if ($v->status() !== 200) { return $v; } // Get the plan $plan = $this->getPlan(); $is_domain = $plan->hasDomain(); $login = $request->login; $domain = $request->domain; // Validate login if ($errors = $this->validateLogin($login, $domain, $is_domain)) { $errors = $this->resolveErrors($errors); return response()->json(['status' => 'error', 'errors' => $errors], 422); } // Get user name/email from the verification code database $code_data = $v->getData(); $user_name = $code_data->name; $user_email = $code_data->email; // We allow only ASCII, so we can safely lower-case the email address $login = Str::lower($login); $domain = Str::lower($domain); DB::beginTransaction(); // Create user record $user = User::create([ 'name' => $user_name, 'email' => $login . '@' . $domain, 'password' => $request->password, ]); // Create domain record // FIXME: Should we do this in UserObserver::created()? if ($is_domain) { $domain = Domain::create([ 'namespace' => $domain, 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); } // Create SKUs (after domain) foreach ($plan->packages as $package) { foreach ($package->skus as $sku) { $sku->registerEntitlement($user, is_object($domain) ? [$domain] : []); } } // Save the external email and plan in user settings $user->setSettings([ 'external_email' => $user_email, 'plan' => $plan->id, ]); // Remove the verification code $this->code->delete(); DB::commit(); return UsersController::logonResponse($user); } /** * Checks if the input string is a valid email address or a phone number * * @param string $email Email address or phone number * @param bool &$is_phone Will be set to True if the string is valid phone number * * @return string Error message label on validation error */ protected function validatePhoneOrEmail($input, &$is_phone = false) { $is_phone = false; return $this->validateEmail($input); // TODO: Phone number support /* if (strpos($input, '@')) { return $this->validateEmail($input); } $input = str_replace(array('-', ' '), '', $input); if (!preg_match('/^\+?[0-9]{9,12}$/', $input)) { return 'validation.noemailorphone'; } $is_phone = true; */ } /** * Email address validation * * @param string $email Email address * * @return string Error message label on validation error */ protected function validateEmail($email) { $v = Validator::make(['email' => $email], ['email' => 'required|email']); if ($v->fails()) { return 'validation.emailinvalid'; } list($local, $domain) = explode('@', $email); // don't allow @localhost and other no-fqdn if (strpos($domain, '.') === false) { return 'validation.emailinvalid'; } } /** * Login (kolab identity) validation * * @param string $email 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 function validateLogin($login, $domain, $external = false) { // don't allow @localhost and other no-fqdn if (empty($domain) || strpos($domain, '.') === false || stripos($domain, 'www.') === 0) { return ['domain' => 'validation.domaininvalid']; } // Local part validation if (!preg_match('/^[A-Za-z0-9_.-]+$/', $login)) { return ['login' => 'validation.logininvalid']; } $domain = Str::lower($domain); 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']; } // 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']; } // TODO: DNS registration check - maybe after signup? // Check if domain is already registered with us if (Domain::where('namespace', $domain)->first()) { return ['domain' => '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 $result; } } diff --git a/src/database/seeds/PlanSeeder.php b/src/database/seeds/PlanSeeder.php index 6ac81b5c..83b9182d 100644 --- a/src/database/seeds/PlanSeeder.php +++ b/src/database/seeds/PlanSeeder.php @@ -1,143 +1,166 @@ 'family', 'description' => 'A group of accounts for 2 or more users.', 'discount_qty' => 0, 'discount_rate' => 0 ] ); $packages = [ Package::firstOrCreate(['title' => 'kolab']), Package::firstOrCreate(['title' => 'domain-hosting']) ]; $plan->packages()->saveMany($packages); $plan->packages()->updateExistingPivot( Package::firstOrCreate(['title' => 'kolab']), [ 'qty_min' => 2, 'qty_max' => -1, 'discount_qty' => 2, 'discount_rate' => 50 ], false ); $plan = Plan::create( [ 'title' => 'small-business', 'description' => 'Accounts for small business owners.', 'discount_qty' => 0, 'discount_rate' => 10 ] ); $packages = [ Package::firstOrCreate(['title' => 'kolab']), Package::firstOrCreate(['title' => 'domain-hosting']) ]; $plan->packages()->saveMany($packages); $plan->packages()->updateExistingPivot( Package::firstOrCreate(['title' => 'kolab']), [ 'qty_min' => 5, 'qty_max' => 25, 'discount_qty' => 5, 'discount_rate' => 30 ], false ); $plan = Plan::create( [ 'title' => 'large-business', 'description' => 'Accounts for large businesses.', 'discount_qty' => 0, 'discount_rate' => 10 ] ); $packages = [ Package::firstOrCreate(['title' => 'kolab']), Package::firstOrCreate(['title' => 'lite']), Package::firstOrCreate(['title' => 'domain-hosting']) ]; $plan->packages()->saveMany($packages); $plan->packages()->updateExistingPivot( Package::firstOrCreate(['title' => 'kolab']), [ 'qty_min' => 20, 'qty_max' => -1, 'discount_qty' => 10, 'discount_rate' => 10 ], false ); $plan->packages()->updateExistingPivot( Package::firstOrCreate(['title' => 'lite']), [ 'qty_min' => 0, 'qty_max' => -1, 'discount_qty' => 10, 'discount_rate' => 10 ], false ); */ + $description = <<<'EOD' +

Everything you need to get started or try Kolab Now, including:

+ +EOD; + $plan = Plan::create( [ 'title' => 'individual', - 'description' => "No friends", + 'description' => $description, 'discount_qty' => 0, 'discount_rate' => 0 ] ); $packages = [ Package::firstOrCreate(['title' => 'kolab']) ]; $plan->packages()->saveMany($packages); + $description = <<<'EOD' +

All the features of the Individual Account, with the following extras:

+ +EOD; + $plan = Plan::create( [ 'title' => 'group', - 'description' => "Some or many friends", + 'description' => $description, 'discount_qty' => 0, 'discount_rate' => 0 ] ); $packages = [ Package::firstOrCreate(['title' => 'kolab']), Package::firstOrCreate(['title' => 'domain-hosting']), ]; $plan->packages()->saveMany($packages); } } diff --git a/src/resources/vue/components/Signup.vue b/src/resources/vue/components/Signup.vue index 4146265f..fa15db5a 100644 --- a/src/resources/vue/components/Signup.vue +++ b/src/resources/vue/components/Signup.vue @@ -1,204 +1,252 @@ diff --git a/src/routes/api.php b/src/routes/api.php index c4906bb9..d2d89493 100644 --- a/src/routes/api.php +++ b/src/routes/api.php @@ -1,47 +1,48 @@ 'api', 'prefix' => 'auth' ], function ($router) { Route::get('info', 'API\UsersController@info'); Route::post('login', 'API\UsersController@login'); Route::post('logout', 'API\UsersController@logout'); Route::post('refresh', 'API\UsersController@refresh'); Route::post('password-reset/init', 'API\PasswordResetController@init'); Route::post('password-reset/verify', 'API\PasswordResetController@verify'); Route::post('password-reset', 'API\PasswordResetController@reset'); + Route::get('signup/plans', 'API\SignupController@plans'); Route::post('signup/init', 'API\SignupController@init'); Route::post('signup/verify', 'API\SignupController@verify'); Route::post('signup', 'API\SignupController@signup'); } ); Route::group( [ 'middleware' => 'auth:api', 'prefix' => 'v4' ], function () { Route::apiResource('entitlements', API\EntitlementsController::class); Route::apiResource('users', API\UsersController::class); Route::apiResource('wallets', API\WalletsController::class); } ); diff --git a/src/tests/Browser/Pages/Signup.php b/src/tests/Browser/Pages/Signup.php index de74b560..22f6e244 100644 --- a/src/tests/Browser/Pages/Signup.php +++ b/src/tests/Browser/Pages/Signup.php @@ -1,47 +1,51 @@ assertPathIs('/signup'); - $browser->assertPresent('@step1'); + $browser->assertPathIs('/signup') + ->assertPresent('@step0') + ->assertPresent('@step1') + ->assertPresent('@step2') + ->assertPresent('@step3'); } /** * Get the element shortcuts for the page. * * @return array */ public function elements(): array { return [ '@app' => '#app', + '@step0' => '#step0', '@step1' => '#step1', '@step2' => '#step2', '@step3' => '#step3', ]; } } diff --git a/src/tests/Browser/SignupTest.php b/src/tests/Browser/SignupTest.php index 025a1de2..72813daf 100644 --- a/src/tests/Browser/SignupTest.php +++ b/src/tests/Browser/SignupTest.php @@ -1,324 +1,494 @@ delete(); + parent::setUp(); + + Domain::where('namespace', 'user-domain-signup.com')->delete(); + User::where('email', 'signuptestdusk@' . \config('app.domain')) + ->orWhere('email', 'admin@user-domain-signup.com') + ->delete(); } /** * Test signup code verification with a link - * - * @return void */ - public function testSignupCodeByLink() + public function testSignupCodeByLink(): void { // Test invalid code (invalid format) $this->browse(function (Browser $browser) { // Register Signup page element selectors we'll be using $browser->onWithoutAssert(new Signup()); // TODO: Test what happens if user is logged in $browser->visit('/signup/invalid-code'); // TODO: According to https://github.com/vuejs/vue-router/issues/977 // it is not yet easily possible to display error page component (route) // without changing the URL // TODO: Instead of css selector we should probably define page/component // and use it instead $browser->waitFor('#error-page'); }); // Test invalid code (valid format) $this->browse(function (Browser $browser) { $browser->visit('/signup/XXXXX-code'); // FIXME: User will not be able to continue anyway, so we should // either display 1st step or 404 error page $browser->waitFor('@step1'); $browser->waitFor('.toast-error'); $browser->click('.toast-error'); // remove the toast }); // Test valid code $this->browse(function (Browser $browser) { $code = SignupCode::create([ 'data' => [ 'email' => 'User@example.org', 'name' => 'User Name', 'plan' => 'individual', ] ]); $browser->visit('/signup/' . $code->short_code . '-' . $code->code); $browser->waitFor('@step3'); $browser->assertMissing('@step1'); $browser->assertMissing('@step2'); // FIXME: Find a nice way to read javascript data without using hidden inputs $this->assertSame($code->code, $browser->value('@step2 #signup_code')); // TODO: Test if the signup process can be completed }); } /** - * Test 1st step of the signup process - * - * @return void + * Test signup "welcome" page */ - public function testSignupStep1() + public function testSignupStep0(): void { $this->browse(function (Browser $browser) { $browser->visit(new Signup()); + $browser->assertVisible('@step0') + ->assertMissing('@step1') + ->assertMissing('@step2') + ->assertMissing('@step3'); + + $browser->within(new Menu(), function ($browser) { + $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'login']); + $browser->assertActiveItem('signup'); + }); + + $browser->waitFor('@step0 .plan-selector > .plan-box'); + + // Assert first plan box and press the button + $browser->with('@step0 .plan-selector > .plan-individual', function ($step) { + $step->assertVisible('button') + ->assertSeeIn('button', 'individual') + ->assertVisible('.plan-description') + ->click('button'); + }); + + $browser->waitForLocation('/signup/individual') + ->assertVisible('@step1') + ->assertMissing('@step0') + ->assertMissing('@step2') + ->assertMissing('@step3') + ->assertFocused('@step1 #signup_name'); + + // Click Back button + $browser->click('@step1 [type=button]') + ->waitForLocation('/signup') + ->assertVisible('@step0') + ->assertMissing('@step1') + ->assertMissing('@step2') + ->assertMissing('@step3'); + + // Choose the group account plan + $browser->click('@step0 .plan-selector > .plan-group button') + ->waitForLocation('/signup/group') + ->assertVisible('@step1') + ->assertMissing('@step0') + ->assertMissing('@step2') + ->assertMissing('@step3') + ->assertFocused('@step1 #signup_name'); + + // TODO: Test if 'plan' variable is set properly in vue component + }); + } + + /** + * Test 1st step of the signup process + */ + public function testSignupStep1(): void + { + $this->browse(function (Browser $browser) { + $browser->visit('/signup/individual')->onWithoutAssert(new Signup()); + $browser->assertVisible('@step1'); $browser->within(new Menu(), function ($browser) { $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'login']); $browser->assertActiveItem('signup'); }); - // Here we expect two text inputs and Continue + // Here we expect two text inputs and Back and Continue buttons $browser->with('@step1', function ($step) { - $step->assertVisible('#signup_name'); - $step->assertFocused('#signup_name'); - $step->assertVisible('#signup_email'); - $step->assertVisible('[type=submit]'); + $step->assertVisible('#signup_name') + ->assertFocused('#signup_name') + ->assertVisible('#signup_email') + ->assertVisible('[type=button]') + ->assertVisible('[type=submit]'); }); // Submit empty form // Both Step 1 inputs are required, so after pressing Submit // we expect focus to be moved to the first input $browser->with('@step1', function ($step) { $step->click('[type=submit]'); $step->assertFocused('#signup_name'); }); // Submit invalid email // We expect email input to have is-invalid class added, with .invalid-feedback element $browser->with('@step1', function ($step) use ($browser) { $step->type('#signup_name', 'Test User'); $step->type('#signup_email', '@test'); $step->click('[type=submit]'); $step->waitFor('#signup_email.is-invalid'); $step->waitFor('#signup_email + .invalid-feedback'); $browser->waitFor('.toast-error'); $browser->click('.toast-error'); // remove the toast }); // Submit valid data // We expect error state on email input to be removed, and Step 2 form visible $browser->with('@step1', function ($step) { $step->type('#signup_name', 'Test User'); $step->type('#signup_email', 'BrowserSignupTestUser1@kolab.org'); $step->click('[type=submit]'); $step->assertMissing('#signup_email.is-invalid'); $step->assertMissing('#signup_email + .invalid-feedback'); }); $browser->waitUntilMissing('@step2 #signup_code[value=""]'); $browser->waitFor('@step2'); $browser->assertMissing('@step1'); }); } /** * Test 2nd Step of the signup process * * @depends testSignupStep1 - * @return void */ - public function testSignupStep2() + public function testSignupStep2(): void { $this->browse(function (Browser $browser) { - $browser->assertVisible('@step2'); + $browser->assertVisible('@step2') + ->assertMissing('@step0') + ->assertMissing('@step1') + ->assertMissing('@step3'); // Here we expect one text input, Back and Continue buttons $browser->with('@step2', function ($step) { - $step->assertVisible('#signup_short_code'); - $step->assertFocused('#signup_short_code'); - $step->assertVisible('[type=button]'); - $step->assertVisible('[type=submit]'); + $step->assertVisible('#signup_short_code') + ->assertFocused('#signup_short_code') + ->assertVisible('[type=button]') + ->assertVisible('[type=submit]'); }); // Test Back button functionality - $browser->click('@step2 [type=button]'); - $browser->waitFor('@step1'); - $browser->assertFocused('@step1 #signup_name'); - $browser->assertMissing('@step2'); + $browser->click('@step2 [type=button]') + ->waitFor('@step1') + ->assertFocused('@step1 #signup_name') + ->assertMissing('@step2'); // Submit valid Step 1 data (again) $browser->with('@step1', function ($step) { $step->type('#signup_name', 'Test User'); $step->type('#signup_email', 'BrowserSignupTestUser1@kolab.org'); $step->click('[type=submit]'); }); $browser->waitFor('@step2'); $browser->assertMissing('@step1'); // Submit invalid code // We expect code input to have is-invalid class added, with .invalid-feedback element $browser->with('@step2', function ($step) use ($browser) { $step->type('#signup_short_code', 'XXXXX'); $step->click('[type=submit]'); $browser->waitFor('.toast-error'); $step->assertVisible('#signup_short_code.is-invalid'); $step->assertVisible('#signup_short_code + .invalid-feedback'); $step->assertFocused('#signup_short_code'); $browser->click('.toast-error'); // remove the toast }); // Submit valid code // We expect error state on code input to be removed, and Step 3 form visible $browser->with('@step2', function ($step) { // Get the code and short_code from database // FIXME: Find a nice way to read javascript data without using hidden inputs $code = $step->value('#signup_code'); $this->assertNotEmpty($code); $code = SignupCode::find($code); $step->type('#signup_short_code', $code->short_code); $step->click('[type=submit]'); $step->assertMissing('#signup_short_code.is-invalid'); $step->assertMissing('#signup_short_code + .invalid-feedback'); }); $browser->waitFor('@step3'); $browser->assertMissing('@step2'); }); } /** * Test 3rd Step of the signup process * * @depends testSignupStep2 - * @return void */ - public function testSignupStep3() + public function testSignupStep3(): void { $this->browse(function (Browser $browser) { $browser->assertVisible('@step3'); // Here we expect 3 text inputs, Back and Continue buttons $browser->with('@step3', function ($step) { $step->assertVisible('#signup_login'); $step->assertVisible('#signup_password'); $step->assertVisible('#signup_confirm'); $step->assertVisible('select#signup_domain'); $step->assertVisible('[type=button]'); $step->assertVisible('[type=submit]'); $step->assertFocused('#signup_login'); $step->assertValue('select#signup_domain', \config('app.domain')); $step->assertValue('#signup_login', ''); $step->assertValue('#signup_password', ''); $step->assertValue('#signup_confirm', ''); // TODO: Test domain selector }); // Test Back button $browser->click('@step3 [type=button]'); $browser->waitFor('@step2'); $browser->assertFocused('@step2 #signup_short_code'); $browser->assertMissing('@step3'); // TODO: Test form reset when going back // Submit valid code again $browser->with('@step2', function ($step) { $code = $step->value('#signup_code'); $this->assertNotEmpty($code); $code = SignupCode::find($code); $step->type('#signup_short_code', $code->short_code); $step->click('[type=submit]'); }); $browser->waitFor('@step3'); // Submit invalid data $browser->with('@step3', function ($step) use ($browser) { $step->assertFocused('#signup_login'); $step->type('#signup_login', '*'); $step->type('#signup_password', '12345678'); $step->type('#signup_confirm', '123456789'); $step->click('[type=submit]'); $browser->waitFor('.toast-error'); $step->assertVisible('#signup_login.is-invalid'); $step->assertVisible('#signup_domain + .invalid-feedback'); $step->assertVisible('#signup_password.is-invalid'); $step->assertVisible('#signup_password + .invalid-feedback'); $step->assertFocused('#signup_login'); $browser->click('.toast-error'); // remove the toast }); // Submit invalid data (valid login, invalid password) $browser->with('@step3', function ($step) use ($browser) { $step->type('#signup_login', 'SignupTestDusk'); $step->click('[type=submit]'); $browser->waitFor('.toast-error'); $step->assertVisible('#signup_password.is-invalid'); $step->assertVisible('#signup_password + .invalid-feedback'); $step->assertMissing('#signup_login.is-invalid'); $step->assertMissing('#signup_domain + .invalid-feedback'); $step->assertFocused('#signup_password'); $browser->click('.toast-error'); // remove the toast }); // Submit valid data $browser->with('@step3', function ($step) { $step->type('#signup_confirm', '12345678'); $step->click('[type=submit]'); }); $browser->waitUntilMissing('@step3'); // At this point we should be auto-logged-in to dashboard $dashboard = new Dashboard(); $dashboard->assert($browser); // FIXME: Is it enough to be sure user is logged in? + + // Logout the user + // TODO: Test what happens if you goto /signup with active session + $browser->click('a.link-logout'); + }); + } + + /** + * Test signup for a group account + */ + public function testSignupGroup(): void + { + $this->browse(function (Browser $browser) { + $browser->visit(new Signup()); + + // Choose the group account plan + $browser->click('@step0 .plan-selector > .plan-group button') + ->waitForLocation('/signup/group'); + + $browser->assertVisible('@step1'); + + // Submit valid data + // We expect error state on email input to be removed, and Step 2 form visible + $browser->with('@step1', function ($step) { + $step->type('#signup_name', 'Test User') + ->type('#signup_email', 'BrowserSignupTestUser1@kolab.org') + ->click('[type=submit]'); + }); + + $browser->waitFor('@step2'); + + // Submit valid code + $browser->with('@step2', function ($step) { + // Get the code and short_code from database + // FIXME: Find a nice way to read javascript data without using hidden inputs + $code = $step->value('#signup_code'); + $code = SignupCode::find($code); + + $step->type('#signup_short_code', $code->short_code) + ->click('[type=submit]'); + }); + + $browser->waitFor('@step3'); + + // Here we expect 4 text inputs, Back and Continue buttons + $browser->with('@step3', function ($step) { + $step->assertVisible('#signup_login') + ->assertVisible('#signup_password') + ->assertVisible('#signup_confirm') + ->assertVisible('input#signup_domain') + ->assertVisible('[type=button]') + ->assertVisible('[type=submit]') + ->assertFocused('#signup_login') + ->assertValue('input#signup_domain', '') + ->assertValue('#signup_login', '') + ->assertValue('#signup_password', '') + ->assertValue('#signup_confirm', ''); + }); + + // Submit invalid login and password data + $browser->with('@step3', function ($step) use ($browser) { + $step->assertFocused('#signup_login') + ->type('#signup_login', '*') + ->type('#signup_domain', 'test.com') + ->type('#signup_password', '12345678') + ->type('#signup_confirm', '123456789') + ->click('[type=submit]'); + + $browser->waitFor('.toast-error'); + + $step->assertVisible('#signup_login.is-invalid') + ->assertVisible('#signup_domain + .invalid-feedback') + ->assertVisible('#signup_password.is-invalid') + ->assertVisible('#signup_password + .invalid-feedback') + ->assertFocused('#signup_login'); + + $browser->click('.toast-error'); // remove the toast + }); + + // Submit invalid domain + $browser->with('@step3', function ($step) use ($browser) { + $step->type('#signup_login', 'admin') + ->type('#signup_domain', 'aaa') + ->type('#signup_password', '12345678') + ->type('#signup_confirm', '12345678') + ->click('[type=submit]'); + + $browser->waitFor('.toast-error'); + + $step->assertMissing('#signup_login.is-invalid') + ->assertVisible('#signup_domain.is-invalid + .invalid-feedback') + ->assertMissing('#signup_password.is-invalid') + ->assertMissing('#signup_password + .invalid-feedback') + ->assertFocused('#signup_domain'); + + $browser->click('.toast-error'); // remove the toast + }); + + // Submit invalid domain + $browser->with('@step3', function ($step) use ($browser) { + $step->type('#signup_domain', 'user-domain-signup.com') + ->click('[type=submit]'); + }); + + $browser->waitUntilMissing('@step3'); + + // At this point we should be auto-logged-in to dashboard + $dashboard = new Dashboard(); + $dashboard->assert($browser); + + // FIXME: Is it enough to be sure user is logged in? + $browser->click('a.link-logout'); }); } } diff --git a/src/tests/Feature/Controller/SignupTest.php b/src/tests/Feature/Controller/SignupTest.php index 5d58c182..e3f72c8d 100644 --- a/src/tests/Feature/Controller/SignupTest.php +++ b/src/tests/Feature/Controller/SignupTest.php @@ -1,604 +1,622 @@ "SignupControllerTest1@$domain"]); } /** * {@inheritDoc} * * @return void */ public function tearDown(): void { $domain = self::getPublicDomain(); User::where('email', "signuplogin@$domain") ->orWhere('email', "SignupControllerTest1@$domain") ->orWhere('email', 'admin@external.com') ->delete(); Domain::where('namespace', 'signup-domain.com') ->orWhere('namespace', 'external.com') ->delete(); } /** * Return a public domain for signup tests */ public function getPublicDomain(): string { if (!self::$domain) { $this->refreshApplication(); self::$domain = Domain::getPublicDomains()[0]; if (empty(self::$domain)) { self::$domain = 'signup-domain.com'; Domain::create([ 'namespace' => self::$domain, 'status' => Domain::STATUS_Active, 'type' => Domain::TYPE_PUBLIC, ]); } } return self::$domain; } + /** + * Test fetching plans for signup + * + * @return void + */ + public function testSignupPlans() + { + // Note: this uses plans that already have been seeded into the DB + + $response = $this->get('/api/auth/signup/plans'); + $json = $response->json(); + + $response->assertStatus(200); + + $this->assertSame('success', $json['status']); + $this->assertCount(2, $json['plans']); + } + /** * Test signup initialization with invalid input * * @return void */ public function testSignupInitInvalidInput() { // Empty input data $data = []; $response = $this->post('/api/auth/signup/init', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertArrayHasKey('email', $json['errors']); $this->assertArrayHasKey('name', $json['errors']); // Data with missing name $data = [ 'email' => 'UsersApiControllerTest1@UsersApiControllerTest.com', ]; $response = $this->post('/api/auth/signup/init', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('name', $json['errors']); // Data with invalid email (but not phone number) $data = [ 'email' => '@example.org', 'name' => 'Signup User', ]; $response = $this->post('/api/auth/signup/init', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('email', $json['errors']); // TODO: Test phone validation } /** * Test signup initialization with valid input * * @return array */ public function testSignupInitValidInput() { Queue::fake(); // Assert that no jobs were pushed... Queue::assertNothingPushed(); $data = [ 'email' => 'testuser@external.com', 'name' => 'Signup User', '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) { // Access protected property $reflection = new \ReflectionClass($job); $code = $reflection->getProperty('code'); $code->setAccessible(true); $code = $code->getValue($job); return $code->code === $json['code'] && $code->data['plan'] === $data['plan'] && $code->data['email'] === $data['email'] && $code->data['name'] === $data['name']; }); return [ 'code' => $json['code'], 'email' => $data['email'], 'name' => $data['name'], 'plan' => $data['plan'], ]; } /** * Test signup code verification with invalid input * * @depends testSignupInitValidInput * @return void */ public function testSignupVerifyInvalidInput(array $result) { // Empty data $data = []; $response = $this->post('/api/auth/signup/verify', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertArrayHasKey('code', $json['errors']); $this->assertArrayHasKey('short_code', $json['errors']); // Data with existing code but missing short_code $data = [ 'code' => $result['code'], ]; $response = $this->post('/api/auth/signup/verify', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('short_code', $json['errors']); // Data with invalid short_code $data = [ 'code' => $result['code'], 'short_code' => 'XXXX', ]; $response = $this->post('/api/auth/signup/verify', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('short_code', $json['errors']); // TODO: Test expired code } /** * Test signup code verification with valid input * * @depends testSignupInitValidInput * * @return array */ public function testSignupVerifyValidInput(array $result) { $code = SignupCode::find($result['code']); $data = [ 'code' => $code->code, 'short_code' => $code->short_code, ]; $response = $this->post('/api/auth/signup/verify', $data); $json = $response->json(); $response->assertStatus(200); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame($result['email'], $json['email']); $this->assertSame($result['name'], $json['name']); $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 * @return void */ public function testSignupInvalidInput(array $result) { // Empty data $data = []; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(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']); // Valid code, invalid login $code = SignupCode::find($result['code']); $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 * @return void */ public function testSignupValidInput(array $result) { $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, ]; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(200); $this->assertCount(4, $json); $this->assertSame('success', $json['status']); $this->assertSame('bearer', $json['token_type']); $this->assertTrue(!empty($json['expires_in']) && is_int($json['expires_in']) && $json['expires_in'] > 0); $this->assertNotEmpty($json['access_token']); // Check if the code has been removed $this->assertNull(SignupCode::where($result['code'])->first()); // Check if the user has been created $user = User::where('email', $identity)->first(); $this->assertNotEmpty($user); $this->assertSame($identity, $user->email); $this->assertSame($result['name'], $user->name); // Check external email in user settings $this->assertSame($result['email'], $user->getSetting('external_email')); // TODO: Check SKUs/Plan // TODO: Check if the access token works } /** * Test signup for a group (custom domain) account * * @return void */ public function testSignupGroupAccount() { Queue::fake(); // Initial signup request $user_data = $data = [ 'email' => 'testuser@external.com', 'name' => 'Signup User', 'plan' => 'group', ]; $response = $this->post('/api/auth/signup/init', $data); $json = $response->json(); $response->assertStatus(200); $this->assertCount(2, $json); $this->assertSame('success', $json['status']); $this->assertNotEmpty($json['code']); // Assert the email sending job was pushed once Queue::assertPushed(\App\Jobs\SignupVerificationEmail::class, 1); // Assert the job has proper data assigned Queue::assertPushed(\App\Jobs\SignupVerificationEmail::class, function ($job) use ($data, $json) { // Access protected property $reflection = new \ReflectionClass($job); $code = $reflection->getProperty('code'); $code->setAccessible(true); $code = $code->getValue($job); return $code->code === $json['code'] && $code->data['plan'] === $data['plan'] && $code->data['email'] === $data['email'] && $code->data['name'] === $data['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(5, $result); $this->assertSame('success', $result['status']); $this->assertSame($user_data['email'], $result['email']); $this->assertSame($user_data['name'], $result['name']); $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->assertCount(4, $result); $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']); // 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); $this->assertSame($user_data['name'], $user->name); // Check domain record // Check external email in user settings $this->assertSame($user_data['email'], $user->getSetting('external_email')); // TODO: Check SKUs/Plan // TODO: Check if the access token works } /** * 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() { $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']], ['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']], // existing user ['SignupControllerTest1', $domain, false, ['login' => 'validation.loginexists']], // Domain account ['admin', 'kolabsys.com', true, null], ['testnonsystemdomain', 'invalid', true, ['domain' => 'validation.domaininvalid']], ['testnonsystemdomain', '.com', true, ['domain' => 'validation.domaininvalid']], // existing user ['SignupControllerTest1', $domain, true, ['domain' => 'validation.domainexists']], ]; } /** * Signup login/domain validation. * * Note: Technicly 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) { $method = new \ReflectionMethod('App\Http\Controllers\API\SignupController', 'validateLogin'); $method->setAccessible(true); $result = $method->invoke(new SignupController(), $login, $domain, $external); $this->assertSame($expected_result, $result); } }