diff --git a/src/app/Http/Controllers/API/SignupController.php b/src/app/Http/Controllers/API/SignupController.php
index b14ccdb3..59613406 100644
--- a/src/app/Http/Controllers/API/SignupController.php
+++ b/src/app/Http/Controllers/API/SignupController.php
@@ -1,391 +1,392 @@
map(function ($plan) use (&$plans) {
- // TODO: Localization
$plans[] = [
'title' => $plan->title,
+ 'name' => $plan->name,
+ 'button' => __('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 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/app/Plan.php b/src/app/Plan.php
index d3b68321..c722ad73 100644
--- a/src/app/Plan.php
+++ b/src/app/Plan.php
@@ -1,81 +1,91 @@
'datetime',
'promo_to' => 'datetime',
'discount_qty' => 'integer',
'discount_rate' => 'integer'
];
+ /** @var array Translatable properties */
+ public $translatable = [
+ 'name',
+ 'description',
+ ];
+
+
public function cost()
{
$costs = 0;
foreach ($this->packages as $package) {
$costs += $package->pivot->cost();
}
return $costs;
}
public function packages()
{
return $this->belongsToMany(
'App\Package',
'plan_packages'
)->using('App\PlanPackage')->withPivot(
[
'qty_min',
'qty_max',
'discount_qty',
'discount_rate'
]
);
}
/**
* Checks if the plan has domain SKU assigned
*/
public function hasDomain(): bool
{
foreach ($this->packages as $package) {
foreach ($package->skus as $sku) {
if ($sku->handler_class::entitleableClass() == \App\Domain::class) {
return true;
}
}
}
return false;
}
}
diff --git a/src/composer.json b/src/composer.json
index 787b0b8c..6d8f070e 100644
--- a/src/composer.json
+++ b/src/composer.json
@@ -1,73 +1,74 @@
{
"name": "laravel/laravel",
"type": "project",
"description": "The Laravel Framework.",
"keywords": [
"framework",
"laravel"
],
"license": "MIT",
"require": {
"php": "^7.1.3",
"doctrine/dbal": "^2.9",
"fideloper/proxy": "^4.0",
"geoip2/geoip2": "^2.9",
"iatstuti/laravel-nullable-fields": "^1.0",
"kolab/net_ldap3": "^1.1",
"laravel/framework": "5.8.*",
"laravel/tinker": "^1.0",
"silviolleite/laravelpwa": "^1.0",
+ "spatie/laravel-translatable": "^4.2",
"swooletw/laravel-swoole": "^2.6",
"torann/currency": "^1.0",
"torann/geoip": "^1.0",
"tymon/jwt-auth": "^1.0"
},
"require-dev": {
"beyondcode/laravel-dump-server": "^1.0",
"beyondcode/laravel-er-diagram-generator": "^1.3",
"filp/whoops": "^2.0",
"fzaninotto/faker": "^1.4",
"laravel/dusk": "^5.5",
"mockery/mockery": "^1.0",
"nunomaduro/collision": "^3.0",
"phpunit/phpunit": "^7.5"
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true
},
"extra": {
"laravel": {
"dont-discover": []
}
},
"autoload": {
"psr-4": {
"App\\": "app/"
},
"classmap": [
"database/seeds",
"database/factories"
]
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"minimum-stability": "dev",
"prefer-stable": true,
"scripts": {
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"@php artisan key:generate --ansi"
]
}
}
diff --git a/src/database/migrations/2019_12_10_105428_create_plans_table.php b/src/database/migrations/2019_12_10_105428_create_plans_table.php
index 403e6ba9..f6d596ee 100644
--- a/src/database/migrations/2019_12_10_105428_create_plans_table.php
+++ b/src/database/migrations/2019_12_10_105428_create_plans_table.php
@@ -1,43 +1,44 @@
string('id', 36);
$table->string('title', 36);
- $table->string('description', 128);
+ $table->json('name');
+ $table->json('description');
$table->datetime('promo_from')->nullable();
$table->datetime('promo_to')->nullable();
$table->integer('qty_min')->default(0)->nullable();
$table->integer('qty_max')->default(0)->nullable();
$table->integer('discount_qty')->default(0);
$table->integer('discount_rate')->default(0);
$table->primary('id');
}
);
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('plans');
}
}
diff --git a/src/database/seeds/PlanSeeder.php b/src/database/seeds/PlanSeeder.php
index 83b9182d..2fe7b041 100644
--- a/src/database/seeds/PlanSeeder.php
+++ b/src/database/seeds/PlanSeeder.php
@@ -1,166 +1,168 @@
'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:
- Perfect for anyone wanting to move to Kolab Now
- Suite of online apps: Secure email, calendar, address book, files and more
- Access for anywhere: Sync all your devices to your Kolab Now account
- Secure hosting: Managed right here on our own servers in Switzerland
- Start protecting your data today, no ads, no crawling, no compromise
- An ideal replacement for services like Gmail, Office 365, etc…
EOD;
$plan = Plan::create(
[
'title' => 'individual',
+ 'name' => 'Individual Account',
'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:
- Perfect for anyone wanting to move a group or small business to Kolab Now
- Recommended to support users from 1 to 100
- Use your own personal domains with Kolab Now
- Manage and add users through our online admin area
- Flexible pricing based on user count
EOD;
$plan = Plan::create(
[
'title' => 'group',
+ 'name' => 'Group Account',
'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/lang/en/app.php b/src/resources/lang/en/app.php
new file mode 100644
index 00000000..3518a77b
--- /dev/null
+++ b/src/resources/lang/en/app.php
@@ -0,0 +1,15 @@
+ 'Choose :plan',
+
+];
diff --git a/src/resources/vue/components/Signup.vue b/src/resources/vue/components/Signup.vue
index fa15db5a..2a9bee7f 100644
--- a/src/resources/vue/components/Signup.vue
+++ b/src/resources/vue/components/Signup.vue
@@ -1,252 +1,252 @@
Sign Up - Step 1/3
Sign up to start your free month.
Sign Up - Step 2/3
We sent out a confirmation code to your email address.
Enter the code we sent you, or click the link in the message.
Sign Up - Step 3/3
Create your Kolab identity (you can choose additional addresses later).
diff --git a/src/tests/Browser/SignupTest.php b/src/tests/Browser/SignupTest.php
index 72813daf..ea2ad12d 100644
--- a/src/tests/Browser/SignupTest.php
+++ b/src/tests/Browser/SignupTest.php
@@ -1,494 +1,494 @@
delete();
User::where('email', 'signuptestdusk@' . \config('app.domain'))
->orWhere('email', 'admin@user-domain-signup.com')
->delete();
}
/**
* Test signup code verification with a link
*/
public function testSignupCodeByLink(): void
{
// Test invalid code (invalid format)
$this->browse(function (Browser $browser) {
// Register Signup page element selectors we'll be using
$browser->onWithoutAssert(new Signup());
// TODO: Test what happens if user is logged in
$browser->visit('/signup/invalid-code');
// TODO: According to https://github.com/vuejs/vue-router/issues/977
// it is not yet easily possible to display error page component (route)
// without changing the URL
// TODO: Instead of css selector we should probably define page/component
// and use it instead
$browser->waitFor('#error-page');
});
// Test invalid code (valid format)
$this->browse(function (Browser $browser) {
$browser->visit('/signup/XXXXX-code');
// FIXME: User will not be able to continue anyway, so we should
// either display 1st step or 404 error page
$browser->waitFor('@step1');
$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 signup "welcome" page
*/
public function testSignupStep0(): void
{
$this->browse(function (Browser $browser) {
$browser->visit(new Signup());
$browser->assertVisible('@step0')
->assertMissing('@step1')
->assertMissing('@step2')
->assertMissing('@step3');
$browser->within(new Menu(), function ($browser) {
$browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'login']);
$browser->assertActiveItem('signup');
});
$browser->waitFor('@step0 .plan-selector > .plan-box');
// Assert first plan box and press the button
$browser->with('@step0 .plan-selector > .plan-individual', function ($step) {
$step->assertVisible('button')
- ->assertSeeIn('button', 'individual')
+ ->assertSeeIn('button', 'Individual Account')
->assertVisible('.plan-description')
->click('button');
});
$browser->waitForLocation('/signup/individual')
->assertVisible('@step1')
->assertMissing('@step0')
->assertMissing('@step2')
->assertMissing('@step3')
->assertFocused('@step1 #signup_name');
// Click Back button
$browser->click('@step1 [type=button]')
->waitForLocation('/signup')
->assertVisible('@step0')
->assertMissing('@step1')
->assertMissing('@step2')
->assertMissing('@step3');
// Choose the group account plan
$browser->click('@step0 .plan-selector > .plan-group button')
->waitForLocation('/signup/group')
->assertVisible('@step1')
->assertMissing('@step0')
->assertMissing('@step2')
->assertMissing('@step3')
->assertFocused('@step1 #signup_name');
// TODO: Test if 'plan' variable is set properly in vue component
});
}
/**
* Test 1st step of the signup process
*/
public function testSignupStep1(): void
{
$this->browse(function (Browser $browser) {
$browser->visit('/signup/individual')->onWithoutAssert(new Signup());
$browser->assertVisible('@step1');
$browser->within(new Menu(), function ($browser) {
$browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'login']);
$browser->assertActiveItem('signup');
});
// Here we expect two text inputs and Back and Continue buttons
$browser->with('@step1', function ($step) {
$step->assertVisible('#signup_name')
->assertFocused('#signup_name')
->assertVisible('#signup_email')
->assertVisible('[type=button]')
->assertVisible('[type=submit]');
});
// Submit empty form
// Both Step 1 inputs are required, so after pressing Submit
// we expect focus to be moved to the first input
$browser->with('@step1', function ($step) {
$step->click('[type=submit]');
$step->assertFocused('#signup_name');
});
// Submit invalid email
// We expect email input to have is-invalid class added, with .invalid-feedback element
$browser->with('@step1', function ($step) use ($browser) {
$step->type('#signup_name', 'Test User');
$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
*/
public function testSignupStep2(): void
{
$this->browse(function (Browser $browser) {
$browser->assertVisible('@step2')
->assertMissing('@step0')
->assertMissing('@step1')
->assertMissing('@step3');
// Here we expect one text input, Back and Continue buttons
$browser->with('@step2', function ($step) {
$step->assertVisible('#signup_short_code')
->assertFocused('#signup_short_code')
->assertVisible('[type=button]')
->assertVisible('[type=submit]');
});
// Test Back button functionality
$browser->click('@step2 [type=button]')
->waitFor('@step1')
->assertFocused('@step1 #signup_name')
->assertMissing('@step2');
// Submit valid Step 1 data (again)
$browser->with('@step1', function ($step) {
$step->type('#signup_name', 'Test User');
$step->type('#signup_email', 'BrowserSignupTestUser1@kolab.org');
$step->click('[type=submit]');
});
$browser->waitFor('@step2');
$browser->assertMissing('@step1');
// Submit invalid code
// We expect code input to have is-invalid class added, with .invalid-feedback element
$browser->with('@step2', function ($step) use ($browser) {
$step->type('#signup_short_code', 'XXXXX');
$step->click('[type=submit]');
$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
*/
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 e3f72c8d..a9e0c5ad 100644
--- a/src/tests/Feature/Controller/SignupTest.php
+++ b/src/tests/Feature/Controller/SignupTest.php
@@ -1,622 +1,626 @@
"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']);
+ $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 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);
}
}
diff --git a/src/tests/Feature/PlanTest.php b/src/tests/Feature/PlanTest.php
new file mode 100644
index 00000000..dc618513
--- /dev/null
+++ b/src/tests/Feature/PlanTest.php
@@ -0,0 +1,81 @@
+delete();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ Plan::where('title', 'test-plan')->delete();
+ }
+
+ /**
+ * Tests for plan attributes localization
+ */
+ public function testPlanLocalization(): void
+ {
+ $plan = Plan::create([
+ 'title' => 'test-plan',
+ 'description' => [
+ 'en' => 'Plan-EN',
+ 'de' => 'Plan-DE',
+ ],
+ 'name' => 'Test',
+ ]);
+
+ $this->assertSame('Plan-EN', $plan->description);
+ $this->assertSame('Test', $plan->name);
+
+ $plan->save();
+ $plan = Plan::where('title', 'test-plan')->first();
+
+ $this->assertSame('Plan-EN', $plan->description);
+ $this->assertSame('Test', $plan->name);
+ $this->assertSame('Plan-DE', $plan->getTranslation('description', 'de'));
+ $this->assertSame('Test', $plan->getTranslation('name', 'de'));
+
+ $plan->setTranslation('name', 'de', 'Prüfung')->save();
+
+ $this->assertSame('Prüfung', $plan->getTranslation('name', 'de'));
+ $this->assertSame('Test', $plan->getTranslation('name', 'en'));
+
+ $plan = Plan::where('title', 'test-plan')->first();
+
+ $this->assertSame('Prüfung', $plan->getTranslation('name', 'de'));
+ $this->assertSame('Test', $plan->getTranslation('name', 'en'));
+
+ // TODO: Test system locale change
+ }
+
+ /**
+ * Tests for Plan::hasDomain()
+ */
+ public function testHasDomain(): void
+ {
+ $plan = Plan::where('title', 'individual')->first();
+
+ $this->assertTrue($plan->hasDomain() === false);
+
+ $plan = Plan::where('title', 'group')->first();
+
+ $this->assertTrue($plan->hasDomain() === true);
+ }
+}