diff --git a/src/app/Domain.php b/src/app/Domain.php
index ad03ffbe..f87249d9 100644
--- a/src/app/Domain.php
+++ b/src/app/Domain.php
@@ -1,157 +1,169 @@
morphOne('App\Entitlement', 'entitleable');
}
+ /**
+ * Return list of public+active domain names
+ */
+ public static function getPublicDomains(): array
+ {
+ $where = sprintf('(type & %s) AND (status & %s)', Domain::TYPE_PUBLIC, Domain::STATUS_ACTIVE);
+
+ return self::whereRaw($where)->get(['namespace'])->map(function ($domain) {
+ return $domain->namespace;
+ })->toArray();
+ }
+
/**
* Returns whether this domain is active.
*
* @return bool
*/
public function isActive(): bool
{
return $this->status & self::STATUS_ACTIVE;
}
/**
* Returns whether this domain is confirmed the ownership of.
*
* @return bool
*/
public function isConfirmed(): bool
{
return $this->status & self::STATUS_CONFIRMED;
}
/**
* Returns whether this domain is deleted.
*
* @return bool
*/
public function isDeleted(): bool
{
return $this->status & self::STATUS_DELETED;
}
/**
* Returns whether this domain is registered with us.
*
* @return bool
*/
public function isExternal(): bool
{
return $this->type & self::TYPE_EXTERNAL;
}
/**
* Returns whether this domain is hosted with us.
*
* @return bool
*/
public function isHosted(): bool
{
return $this->type & self::TYPE_HOSTED;
}
/**
* Returns whether this domain is new.
*
* @return bool
*/
public function isNew(): bool
{
return $this->status & self::STATUS_NEW;
}
/**
* Returns whether this domain is public.
*
* @return bool
*/
public function isPublic(): bool
{
return $this->type & self::TYPE_PUBLIC;
}
/**
* Returns whether this domain is suspended.
*
* @return bool
*/
public function isSuspended(): bool
{
return $this->status & self::STATUS_SUSPENDED;
}
/*
public function setStatusAttribute($status)
{
$_status = $this->status;
switch ($status) {
case "new":
$_status += self::STATUS_NEW;
break;
case "active":
$_status += self::STATUS_ACTIVE;
$_status -= self::STATUS_NEW;
break;
case "confirmed":
$_status += self::STATUS_CONFIRMED;
$_status -= self::STATUS_NEW;
break;
case "suspended":
$_status += self::STATUS_SUSPENDED;
break;
case "deleted":
$_status += self::STATUS_DELETED;
break;
default:
$_status = $status;
//throw new \Exception("Invalid domain status: {$status}");
break;
}
$this->status = $_status;
}
*/
}
diff --git a/src/app/Handlers/Domain.php b/src/app/Handlers/Domain.php
index c8db9f67..231a2ecb 100644
--- a/src/app/Handlers/Domain.php
+++ b/src/app/Handlers/Domain.php
@@ -1,17 +1,23 @@
sku_id)->active) {
+ \Log::error("Sku not active");
return false;
}
return true;
}
}
diff --git a/src/app/Handlers/DomainHosting.php b/src/app/Handlers/DomainHosting.php
index 78c1d647..2ec21573 100644
--- a/src/app/Handlers/DomainHosting.php
+++ b/src/app/Handlers/DomainHosting.php
@@ -1,17 +1,23 @@
sku_id)->active) {
+ \Log::error("Sku not active");
return false;
}
- return false;
+ return true;
}
}
diff --git a/src/app/Handlers/DomainRegistration.php b/src/app/Handlers/DomainRegistration.php
index ff5494a5..367c3db9 100644
--- a/src/app/Handlers/DomainRegistration.php
+++ b/src/app/Handlers/DomainRegistration.php
@@ -1,17 +1,23 @@
sku_id)->active) {
+ \Log::error("Sku not active");
return false;
}
- return false;
+ return true;
}
}
diff --git a/src/app/Handlers/Groupware.php b/src/app/Handlers/Groupware.php
index fd6d97dc..5f6f443d 100644
--- a/src/app/Handlers/Groupware.php
+++ b/src/app/Handlers/Groupware.php
@@ -1,17 +1,23 @@
sku_id)->active) {
+ \Log::error("Sku not active");
return false;
}
return true;
}
}
diff --git a/src/app/Handlers/Mailbox.php b/src/app/Handlers/Mailbox.php
index 3c63c604..b6ce7b8b 100644
--- a/src/app/Handlers/Mailbox.php
+++ b/src/app/Handlers/Mailbox.php
@@ -1,32 +1,43 @@
sku_id)->active) {
- \Log::info("Sku not active");
+ \Log::error("Sku not active");
return false;
}
+/*
+ FIXME: This code prevents from creating initial mailbox SKU
+ on signup of group account, because User::domains()
+ does not return the new domain.
+ Either we make sure to create domain entitlement before mailbox
+ entitlement or make the method here aware of that case or?
list($local, $domain) = explode('@', $user->email);
$domains = $user->domains();
foreach ($domains as $_domain) {
if ($domain == $_domain->namespace) {
return true;
}
}
\Log::info("Domain not for user");
-
- return false;
+*/
+ return true;
}
}
diff --git a/src/app/Handlers/Resource.php b/src/app/Handlers/Resource.php
index 51da6b15..34f8e7e5 100644
--- a/src/app/Handlers/Resource.php
+++ b/src/app/Handlers/Resource.php
@@ -1,17 +1,23 @@
sku_id)->active) {
+ \Log::error("Sku not active");
return false;
}
return true;
}
}
diff --git a/src/app/Handlers/SharedFolder.php b/src/app/Handlers/SharedFolder.php
index a30905a4..7dda33ed 100644
--- a/src/app/Handlers/SharedFolder.php
+++ b/src/app/Handlers/SharedFolder.php
@@ -1,17 +1,23 @@
sku_id)->active) {
+ \Log::error("Sku not active");
return false;
}
return true;
}
}
diff --git a/src/app/Handlers/Storage.php b/src/app/Handlers/Storage.php
index 631e9ca6..94ac4642 100644
--- a/src/app/Handlers/Storage.php
+++ b/src/app/Handlers/Storage.php
@@ -1,17 +1,34 @@
user_id = $user->id;
+ $quota->save();
+
+ return $quota->id;
+ }
+
+ public static function entitleableClass()
+ {
+ return Quota::class;
+ }
+
public static function preReq($entitlement, $user)
{
if (!Sku::find($entitlement->sku_id)->active) {
+ \Log::error("Sku not active");
return false;
}
return true;
}
}
diff --git a/src/app/Http/Controllers/API/SignupController.php b/src/app/Http/Controllers/API/SignupController.php
index c92290eb..7a6be7ef 100644
--- a/src/app/Http/Controllers/API/SignupController.php
+++ b/src/app/Http/Controllers/API/SignupController.php
@@ -1,255 +1,369 @@
all(),
[
'email' => 'required',
- 'name' => '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;
- // Return user name and email/phone from the codes database on success
+ $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);
}
- $login = $request->login . '@' . \config('app.domain');
-
- // Validate login (email)
- if ($error = $this->validateEmail($login, true)) {
- return response()->json(['status' => 'error', 'errors' => ['login' => $error]], 422);
- }
-
// Validate verification codes (again)
$v = $this->verify($request);
if ($v->status() !== 200) {
return $v;
}
+ // Get 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);
- $user = User::create(
- [
+ DB::beginTransaction();
+
+ // Create user record
+ $user = User::create([
'name' => $user_name,
- 'email' => $login,
+ 'email' => $login . '@' . $domain,
'password' => $request->password,
- ]
- );
+ ]);
- // Save the external email in user settings
- $user->setSettings(['external_email' => $user_email]);
+ // 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
- * @param bool $signup Enables additional checks for signup mode
+ * @param string $email Email address
*
* @return string Error message label on validation error
*/
- protected function validateEmail($email, $signup = false)
+ 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'];
+ }
- // Extended checks for an address that is supposed to become a login to Kolab
- if ($signup) {
- // Local part validation
- if (!preg_match('/^[A-Za-z0-9_.-]+$/', $local)) {
- return 'validation.emailinvalid';
+ // 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 ($domain != \config('app.domain')) {
- return 'validation.emailinvalid';
+ 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'];
}
- // Check if the local part is not one of exceptions
- $exceptions = '/^(admin|administrator|sales|root)$/i';
- if (preg_match($exceptions, $local)) {
- return 'validation.emailexists';
+ // 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();
}
- // Check if user with specified login already exists
- // TODO: Aliases
- if (User::where('email', $email)->first()) {
- return 'validation.emailexists';
+ // ...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 746be3b6..d3b68321 100644
--- a/src/app/Plan.php
+++ b/src/app/Plan.php
@@ -1,65 +1,81 @@
'datetime',
'promo_to' => 'datetime',
'discount_qty' => 'integer',
'discount_rate' => 'integer'
];
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/app/Quota.php b/src/app/Quota.php
index a389cbd0..55c1797e 100644
--- a/src/app/Quota.php
+++ b/src/app/Quota.php
@@ -1,13 +1,29 @@
'int',
+ ];
+
public function entitlement()
{
return $this->morphOne('App\Entitlement', 'entitleable');
}
+
+ /**
+ * The owner of this quota entry
+ *
+ * @return \App\User
+ */
+ public function user()
+ {
+ return $this->belongsTo('App\User', 'user_id', 'id');
+ }
}
diff --git a/src/app/Sku.php b/src/app/Sku.php
index 3dc4bab7..a99718c7 100644
--- a/src/app/Sku.php
+++ b/src/app/Sku.php
@@ -1,46 +1,85 @@
'integer'
];
protected $fillable = [
'title',
'description',
'cost',
'units_free',
'period',
'handler_class',
'active'
];
/**
* List the entitlements that consume this SKU.
*
* @return Entitlement[]
*/
public function entitlements()
{
return $this->hasMany('App\Entitlement');
}
public function packages()
{
return $this->belongsToMany(
'App\Package',
'package_skus'
)->using('App\PackageSku')->withPivot(['qty']);
}
+
+ /**
+ * Register (default) SKU entitlement for specified user.
+ * This method should be used e.g. on user creation when we have
+ * a set of SKUs and want to create entitlements for them (using
+ * default values).
+ */
+ public function registerEntitlement(\App\User $user, array $params = [])
+ {
+ $wallet = $user->wallets()->get()[0];
+
+ $entitlement = new \App\Entitlement();
+ $entitlement->owner_id = $user->id;
+ $entitlement->wallet_id = $wallet->id;
+ $entitlement->sku_id = $this->id;
+
+ $entitlement->entitleable_type = $this->handler_class::entitleableClass();
+
+ if ($user instanceof $entitlement->entitleable_type) {
+ $entitlement->entitleable_id = $user->id;
+ } else {
+ foreach ($params as $param) {
+ if ($param instanceof $entitlement->entitleable_type) {
+ $entitlement->entitleable_id = $param->id;
+ break;
+ }
+ }
+ }
+
+ if (empty($entitlement->entitleable_id)) {
+ if (method_exists($this->handler_class, 'createDefaultEntitleable')) {
+ $entitlement->entitleable_id = $this->handler_class::createDefaultEntitleable($user);
+ } else {
+ // error
+ }
+ }
+
+ $entitlement->save();
+ }
}
diff --git a/src/app/User.php b/src/app/User.php
index 4a2d8bcc..43cc8912 100644
--- a/src/app/User.php
+++ b/src/app/User.php
@@ -1,206 +1,206 @@
'datetime',
];
/**
* Any wallets on which this user is a controller.
*
* @return Wallet[]
*/
public function accounts()
{
return $this->belongsToMany(
'App\Wallet', // The foreign object definition
'user_accounts', // The table name
'user_id', // The local foreign key
'wallet_id' // The remote foreign key
);
}
/**
* List the domains to which this user is entitled.
*
* @return Domain[]
*/
public function domains()
{
$domains = Domain::whereRaw(
sprintf(
'(type & %s) AND (status & %s)',
Domain::TYPE_PUBLIC,
Domain::STATUS_ACTIVE
)
)->get();
foreach ($this->entitlements()->get() as $entitlement) {
if ($entitlement->entitleable instanceof Domain) {
$domain = Domain::find($entitlement->entitleable_id);
\Log::info("Found domain {$domain->namespace}");
$domains[] = $domain;
}
}
foreach ($this->accounts()->get() as $wallet) {
foreach ($wallet->entitlements()->get() as $entitlement) {
if ($entitlement->entitleable instanceof Domain) {
$domain = Domain::find($entitlement->entitleable_id);
\Log::info("Found domain {$domain->namespace}");
$domains[] = $domain;
}
}
}
return $domains;
}
public function entitlement()
{
return $this->morphOne('App\Entitlement', 'entitleable');
}
/**
* Entitlements for this user.
*
* @return Entitlement[]
*/
public function entitlements()
{
return $this->hasMany('App\Entitlement', 'owner_id', 'id');
}
public function addEntitlement($entitlement)
{
if (!$this->entitlements()->get()->contains($entitlement)) {
return $this->entitlements()->save($entitlement);
}
}
/**
* Helper to find user by email address, whether it is
* main email address, alias or external email
*
* @param string $email Email address
*
* @return \App\User User model object
*/
public static function findByEmail(string $email)
{
if (strpos($email, '@') === false) {
return;
}
- $user = self::where('email', $email);
-
- return $user->count() === 1 ? $user->first() : null;
+ $user = self::where('email', $email)->first();
// TODO: Aliases, External email
+
+ return $user;
}
public function settings()
{
return $this->hasMany('App\UserSetting', 'user_id');
}
/**
* Verification codes for this user.
*
* @return VerificationCode[]
*/
public function verificationcodes()
{
return $this->hasMany('App\VerificationCode', 'user_id', 'id');
}
/**
* Wallets this user owns.
*
* @return Wallet[]
*/
public function wallets()
{
return $this->hasMany('App\Wallet');
}
public function getJWTIdentifier()
{
return $this->getKey();
}
public function getJWTCustomClaims()
{
return [];
}
public function setPasswordAttribute($password)
{
if (!empty($password)) {
$this->attributes['password'] = bcrypt($password, [ "rounds" => 12 ]);
$this->attributes['password_ldap'] = '{SSHA512}' . base64_encode(
pack('H*', hash('sha512', $password))
);
}
}
public function setPasswordLdapAttribute($password)
{
if (!empty($password)) {
$this->attributes['password'] = bcrypt($password, [ "rounds" => 12 ]);
$this->attributes['password_ldap'] = '{SSHA512}' . base64_encode(
pack('H*', hash('sha512', $password))
);
}
}
}
diff --git a/src/database/migrations/2019_09_23_071701_create_domains_table.php b/src/database/migrations/2019_09_23_071701_create_domains_table.php
index 61e7a16d..06399128 100644
--- a/src/database/migrations/2019_09_23_071701_create_domains_table.php
+++ b/src/database/migrations/2019_09_23_071701_create_domains_table.php
@@ -1,34 +1,34 @@
bigInteger('id');
- $table->string('namespace');
+ $table->string('namespace')->unique();
$table->smallinteger('status');
$table->tinyinteger('type');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('domains');
}
}
diff --git a/src/package.json b/src/package.json
index fafca483..926ddfa0 100644
--- a/src/package.json
+++ b/src/package.json
@@ -1,34 +1,34 @@
{
"private": true,
"scripts": {
"dev": "npm run development",
"development": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js",
"watch": "npm run development -- --watch",
"watch-poll": "npm run watch -- --watch-poll",
"hot": "cross-env NODE_ENV=development node_modules/webpack-dev-server/bin/webpack-dev-server.js --inline --hot --config=node_modules/laravel-mix/setup/webpack.config.js",
"prod": "npm run production",
"production": "cross-env NODE_ENV=production node_modules/webpack/bin/webpack.js --no-progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js",
"lint": "eslint --ext .js,.vue resources && stylelint \"resources/sass/*.scss\" \"resources/vue/components/*.vue\""
},
"devDependencies": {
"@deveodk/vue-toastr": "^1.1.0",
"axios": "^0.19",
- "bootstrap": "^4.3.1",
+ "bootstrap": "^4.4.1",
"cross-env": "^5.1",
"eslint": "^6.8.0",
"eslint-plugin-vue": "^6.1.1",
"jquery": "^3.4.1",
"laravel-mix": "^4.0.7",
"lodash": "^4.17.13",
"popper.js": "^1.12",
"resolve-url-loader": "^2.3.1",
"sass": "^1.15.2",
"sass-loader": "^7.1.0",
"stylelint": "^12.0.1",
"stylelint-config-standard": "^19.0.0",
"vue": "^2.5.17",
"vue-router": "^3.1.3",
"vue-template-compiler": "^2.6.10",
"vuex": "^3.1.1"
}
}
diff --git a/src/resources/lang/en/validation.php b/src/resources/lang/en/validation.php
index 23261b79..88de81ae 100644
--- a/src/resources/lang/en/validation.php
+++ b/src/resources/lang/en/validation.php
@@ -1,156 +1,159 @@
'The :attribute must be accepted.',
'active_url' => 'The :attribute is not a valid URL.',
'after' => 'The :attribute must be a date after :date.',
'after_or_equal' => 'The :attribute must be a date after or equal to :date.',
'alpha' => 'The :attribute may only contain letters.',
'alpha_dash' => 'The :attribute may only contain letters, numbers, dashes and underscores.',
'alpha_num' => 'The :attribute may only contain letters and numbers.',
'array' => 'The :attribute must be an array.',
'before' => 'The :attribute must be a date before :date.',
'before_or_equal' => 'The :attribute must be a date before or equal to :date.',
'between' => [
'numeric' => 'The :attribute must be between :min and :max.',
'file' => 'The :attribute must be between :min and :max kilobytes.',
'string' => 'The :attribute must be between :min and :max characters.',
'array' => 'The :attribute must have between :min and :max items.',
],
'boolean' => 'The :attribute field must be true or false.',
'confirmed' => 'The :attribute confirmation does not match.',
'date' => 'The :attribute is not a valid date.',
'date_equals' => 'The :attribute must be a date equal to :date.',
'date_format' => 'The :attribute does not match the format :format.',
'different' => 'The :attribute and :other must be different.',
'digits' => 'The :attribute must be :digits digits.',
'digits_between' => 'The :attribute must be between :min and :max digits.',
'dimensions' => 'The :attribute has invalid image dimensions.',
'distinct' => 'The :attribute field has a duplicate value.',
'email' => 'The :attribute must be a valid email address.',
'ends_with' => 'The :attribute must end with one of the following: :values',
'exists' => 'The selected :attribute is invalid.',
'file' => 'The :attribute must be a file.',
'filled' => 'The :attribute field must have a value.',
'gt' => [
'numeric' => 'The :attribute must be greater than :value.',
'file' => 'The :attribute must be greater than :value kilobytes.',
'string' => 'The :attribute must be greater than :value characters.',
'array' => 'The :attribute must have more than :value items.',
],
'gte' => [
'numeric' => 'The :attribute must be greater than or equal :value.',
'file' => 'The :attribute must be greater than or equal :value kilobytes.',
'string' => 'The :attribute must be greater than or equal :value characters.',
'array' => 'The :attribute must have :value items or more.',
],
'image' => 'The :attribute must be an image.',
'in' => 'The selected :attribute is invalid.',
'in_array' => 'The :attribute field does not exist in :other.',
'integer' => 'The :attribute must be an integer.',
'ip' => 'The :attribute must be a valid IP address.',
'ipv4' => 'The :attribute must be a valid IPv4 address.',
'ipv6' => 'The :attribute must be a valid IPv6 address.',
'json' => 'The :attribute must be a valid JSON string.',
'lt' => [
'numeric' => 'The :attribute must be less than :value.',
'file' => 'The :attribute must be less than :value kilobytes.',
'string' => 'The :attribute must be less than :value characters.',
'array' => 'The :attribute must have less than :value items.',
],
'lte' => [
'numeric' => 'The :attribute must be less than or equal :value.',
'file' => 'The :attribute must be less than or equal :value kilobytes.',
'string' => 'The :attribute must be less than or equal :value characters.',
'array' => 'The :attribute must not have more than :value items.',
],
'max' => [
'numeric' => 'The :attribute may not be greater than :max.',
'file' => 'The :attribute may not be greater than :max kilobytes.',
'string' => 'The :attribute may not be greater than :max characters.',
'array' => 'The :attribute may not have more than :max items.',
],
'mimes' => 'The :attribute must be a file of type: :values.',
'mimetypes' => 'The :attribute must be a file of type: :values.',
'min' => [
'numeric' => 'The :attribute must be at least :min.',
'file' => 'The :attribute must be at least :min kilobytes.',
'string' => 'The :attribute must be at least :min characters.',
'array' => 'The :attribute must have at least :min items.',
],
'not_in' => 'The selected :attribute is invalid.',
'not_regex' => 'The :attribute format is invalid.',
'numeric' => 'The :attribute must be a number.',
'present' => 'The :attribute field must be present.',
'regex' => 'The :attribute format is invalid.',
'required' => 'The :attribute field is required.',
'required_if' => 'The :attribute field is required when :other is :value.',
'required_unless' => 'The :attribute field is required unless :other is in :values.',
'required_with' => 'The :attribute field is required when :values is present.',
'required_with_all' => 'The :attribute field is required when :values are present.',
'required_without' => 'The :attribute field is required when :values is not present.',
'required_without_all' => 'The :attribute field is required when none of :values are present.',
'same' => 'The :attribute and :other must match.',
'size' => [
'numeric' => 'The :attribute must be :size.',
'file' => 'The :attribute must be :size kilobytes.',
'string' => 'The :attribute must be :size characters.',
'array' => 'The :attribute must contain :size items.',
],
'starts_with' => 'The :attribute must start with one of the following: :values',
'string' => 'The :attribute must be a string.',
'timezone' => 'The :attribute must be a valid zone.',
'unique' => 'The :attribute has already been taken.',
'uploaded' => 'The :attribute failed to upload.',
'url' => 'The :attribute format is invalid.',
'uuid' => 'The :attribute must be a valid UUID.',
- 'emailexists' => 'The specified email address already exists',
'emailinvalid' => 'The specified email address is invalid',
+ 'domaininvalid' => 'The specified domain is invalid',
+ 'logininvalid' => 'The specified login contains forbidden characters',
+ 'loginexists' => 'The specified login is not available for signup',
+ 'domainexists' => 'The specified domain is not available for signup',
'noemailorphone' => 'The specified text is neither a valid email address nor a phone number',
'usernotexists' => 'Unable to find user',
'noextemail' => 'This user has no external email address',
/*
|--------------------------------------------------------------------------
| Custom Validation Language Lines
|--------------------------------------------------------------------------
|
| Here you may specify custom validation messages for attributes using the
| convention "attribute.rule" to name the lines. This makes it quick to
| specify a specific custom language line for a given attribute rule.
|
*/
'custom' => [
'attribute-name' => [
'rule-name' => 'custom-message',
],
],
/*
|--------------------------------------------------------------------------
| Custom Validation Attributes
|--------------------------------------------------------------------------
|
| The following language lines are used to swap our attribute placeholder
| with something more reader friendly such as "E-Mail Address" instead
| of "email". This simply helps us make our message more expressive.
|
*/
'attributes' => [],
];
diff --git a/src/resources/vue/components/Signup.vue b/src/resources/vue/components/Signup.vue
index 70ace083..4146265f 100644
--- a/src/resources/vue/components/Signup.vue
+++ b/src/resources/vue/components/Signup.vue
@@ -1,183 +1,204 @@
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/resources/vue/js/routes.js b/src/resources/vue/js/routes.js
index edbd1805..0e81ff1f 100644
--- a/src/resources/vue/js/routes.js
+++ b/src/resources/vue/js/routes.js
@@ -1,76 +1,76 @@
import Vue from 'vue'
import VueRouter from 'vue-router'
Vue.use(VueRouter)
import DashboardComponent from '../components/Dashboard'
import Error404Component from '../components/404'
import LoginComponent from '../components/Login'
import LogoutComponent from '../components/Logout'
import PasswordResetComponent from '../components/PasswordReset'
import SignupComponent from '../components/Signup'
import store from './store'
const routes = [
{
path: '/',
redirect: { name: 'login' }
},
{
path: '/dashboard',
name: 'dashboard',
component: DashboardComponent,
meta: { requiresAuth: true }
},
{
path: '/login',
name: 'login',
component: LoginComponent
},
{
path: '/logout',
name: 'logout',
component: LogoutComponent
},
{
path: '/password-reset/:code?',
name: 'password-reset',
component: PasswordResetComponent
},
{
- path: '/signup/:code?',
+ path: '/signup/:param?',
name: 'signup',
component: SignupComponent
},
{
name: '404',
path: '*',
component: Error404Component
}
]
const router = new VueRouter({
mode: 'history',
routes
})
router.beforeEach((to, from, next) => {
// check if the route requires authentication and user is not logged in
if (to.matched.some(route => route.meta.requiresAuth) && !store.state.isLoggedIn) {
// redirect to login page
next({ name: 'login' })
return
}
// if logged in redirect to dashboard
if (to.path === '/login' && store.state.isLoggedIn) {
next({ name: 'dashboard' })
return
}
next()
})
export default router
diff --git a/src/tests/Browser/PasswordResetTest.php b/src/tests/Browser/PasswordResetTest.php
index 18cc8815..8baf8e48 100644
--- a/src/tests/Browser/PasswordResetTest.php
+++ b/src/tests/Browser/PasswordResetTest.php
@@ -1,277 +1,275 @@
'passwordresettestdusk@' . \config('app.domain')]);
$user->setSetting('external_email', 'external@domain.tld');
}
/**
* {@inheritDoc}
*
* @return void
*/
public function tearDown(): void
{
User::where('email', 'passwordresettestdusk@' . \config('app.domain'))->delete();
-
- parent::tearDown();
}
/**
* Test the link from logon to password-reset page
*
* @return void
*/
public function testPasswordResetLinkOnLogon()
{
$this->browse(function (Browser $browser) {
$browser->visit(new Home());
$browser->assertSeeLink('Forgot password?');
$browser->clickLink('Forgot password?');
$browser->on(new PasswordReset());
$browser->assertVisible('@step1');
});
}
/**
* Test 1st step of password-reset
*
* @return void
*/
public function testPasswordResetStep1()
{
$this->browse(function (Browser $browser) {
$browser->visit(new PasswordReset());
$browser->assertVisible('@step1');
// Here we expect email input and submit button
$browser->with('@step1', function ($step) {
$step->assertVisible('#reset_email');
$step->assertFocused('#reset_email');
$step->assertVisible('[type=submit]');
});
// Submit empty form
$browser->with('@step1', function ($step) {
$step->click('[type=submit]');
$step->assertFocused('#reset_email');
});
// Submit invalid email
// We expect email input to have is-invalid class added, with .invalid-feedback element
$browser->with('@step1', function ($step) use ($browser) {
$step->type('#reset_email', '@test');
$step->click('[type=submit]');
$step->waitFor('#reset_email.is-invalid');
$step->waitFor('#reset_email + .invalid-feedback');
$browser->waitFor('.toast-error');
$browser->click('.toast-error'); // remove the toast
});
// Submit valid data
$browser->with('@step1', function ($step) {
$step->type('#reset_email', 'passwordresettestdusk@' . \config('app.domain'));
$step->click('[type=submit]');
$step->assertMissing('#reset_email.is-invalid');
$step->assertMissing('#reset_email + .invalid-feedback');
});
$browser->waitUntilMissing('@step2 #reset_code[value=""]');
$browser->waitFor('@step2');
$browser->assertMissing('@step1');
});
}
/**
* Test 2nd Step of the password reset process
*
* @depends testPasswordResetStep1
* @return void
*/
public function testPasswordResetStep2()
{
$this->browse(function (Browser $browser) {
$browser->assertVisible('@step2');
// Here we expect one text input, Back and Continue buttons
$browser->with('@step2', function ($step) {
$step->assertVisible('#reset_short_code');
$step->assertFocused('#reset_short_code');
$step->assertVisible('[type=button]');
$step->assertVisible('[type=submit]');
});
// Test Back button functionality
$browser->click('@step2 [type=button]');
$browser->waitFor('@step1');
$browser->assertFocused('@step1 #reset_email');
$browser->assertMissing('@step2');
// Submit valid Step 1 data (again)
$browser->with('@step1', function ($step) {
$step->type('#reset_email', 'passwordresettestdusk@' . \config('app.domain'));
$step->click('[type=submit]');
});
$browser->waitFor('@step2');
$browser->assertMissing('@step1');
// Submit invalid code
// We expect code input to have is-invalid class added, with .invalid-feedback element
$browser->with('@step2', function ($step) use ($browser) {
$step->type('#reset_short_code', 'XXXXX');
$step->click('[type=submit]');
$browser->waitFor('.toast-error');
$step->assertVisible('#reset_short_code.is-invalid');
$step->assertVisible('#reset_short_code + .invalid-feedback');
$step->assertFocused('#reset_short_code');
$browser->click('.toast-error'); // remove the toast
});
// Submit valid code
// We expect error state on code input to be removed, and Step 3 form visible
$browser->with('@step2', function ($step) {
// Get the code and short_code from database
// FIXME: Find a nice way to read javascript data without using hidden inputs
$code = $step->value('#reset_code');
$this->assertNotEmpty($code);
$code = VerificationCode::find($code);
$step->type('#reset_short_code', $code->short_code);
$step->click('[type=submit]');
$step->assertMissing('#reset_short_code.is-invalid');
$step->assertMissing('#reset_short_code + .invalid-feedback');
});
$browser->waitFor('@step3');
$browser->assertMissing('@step2');
});
}
/**
* Test 3rd Step of the password reset process
*
* @depends testPasswordResetStep2
* @return void
*/
public function testPasswordResetStep3()
{
$this->browse(function (Browser $browser) {
$browser->assertVisible('@step3');
// Here we expect 2 text inputs, Back and Continue buttons
$browser->with('@step3', function ($step) {
$step->assertVisible('#reset_password');
$step->assertVisible('#reset_confirm');
$step->assertVisible('[type=button]');
$step->assertVisible('[type=submit]');
$step->assertFocused('#reset_password');
});
// Test Back button
$browser->click('@step3 [type=button]');
$browser->waitFor('@step2');
$browser->assertFocused('@step2 #reset_short_code');
$browser->assertMissing('@step3');
$browser->assertMissing('@step1');
// TODO: Test form reset when going back
// Because the verification code is removed in tearDown()
// we'll start from the beginning (Step 1)
$browser->click('@step2 [type=button]');
$browser->waitFor('@step1');
$browser->assertFocused('@step1 #reset_email');
$browser->assertMissing('@step3');
$browser->assertMissing('@step2');
// Submit valid data
$browser->with('@step1', function ($step) {
$step->type('#reset_email', 'passwordresettestdusk@' . \config('app.domain'));
$step->click('[type=submit]');
});
$browser->waitFor('@step2');
$browser->waitUntilMissing('@step2 #reset_code[value=""]');
// Submit valid code again
$browser->with('@step2', function ($step) {
$code = $step->value('#reset_code');
$this->assertNotEmpty($code);
$code = VerificationCode::find($code);
$step->type('#reset_short_code', $code->short_code);
$step->click('[type=submit]');
});
$browser->waitFor('@step3');
// Submit invalid data
$browser->with('@step3', function ($step) use ($browser) {
$step->assertFocused('#reset_password');
$step->type('#reset_password', '12345678');
$step->type('#reset_confirm', '123456789');
$step->click('[type=submit]');
$browser->waitFor('.toast-error');
$step->assertVisible('#reset_password.is-invalid');
$step->assertVisible('#reset_password + .invalid-feedback');
$step->assertFocused('#reset_password');
$browser->click('.toast-error'); // remove the toast
});
// Submit valid data
$browser->with('@step3', function ($step) {
$step->type('#reset_confirm', '12345678');
$step->click('[type=submit]');
});
$browser->waitUntilMissing('@step3');
// At this point we should be auto-logged-in to dashboard
$dashboard = new Dashboard();
$dashboard->assert($browser);
// FIXME: Is it enough to be sure user is logged in?
});
}
}
diff --git a/src/tests/Browser/SignupTest.php b/src/tests/Browser/SignupTest.php
index 6cce96e2..025a1de2 100644
--- a/src/tests/Browser/SignupTest.php
+++ b/src/tests/Browser/SignupTest.php
@@ -1,319 +1,324 @@
delete();
-
- parent::tearDown();
}
/**
* Test signup code verification with a link
*
* @return void
*/
public function testSignupCodeByLink()
{
// Test invalid code (invalid format)
$this->browse(function (Browser $browser) {
// Register Signup page element selectors we'll be using
$browser->onWithoutAssert(new Signup());
// TODO: Test what happens if user is logged in
$browser->visit('/signup/invalid-code');
// TODO: According to https://github.com/vuejs/vue-router/issues/977
// it is not yet easily possible to display error page component (route)
// without changing the URL
// TODO: Instead of css selector we should probably define page/component
// and use it instead
$browser->waitFor('#error-page');
});
// Test invalid code (valid format)
$this->browse(function (Browser $browser) {
$browser->visit('/signup/XXXXX-code');
// FIXME: User will not be able to continue anyway, so we should
// either display 1st step or 404 error page
$browser->waitFor('@step1');
$browser->waitFor('.toast-error');
$browser->click('.toast-error'); // remove the toast
});
// Test valid code
$this->browse(function (Browser $browser) {
$code = SignupCode::create([
'data' => [
'email' => 'User@example.org',
'name' => 'User Name',
+ '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
*/
public function testSignupStep1()
{
$this->browse(function (Browser $browser) {
$browser->visit(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
$browser->with('@step1', function ($step) {
$step->assertVisible('#signup_name');
$step->assertFocused('#signup_name');
$step->assertVisible('#signup_email');
$step->assertVisible('[type=submit]');
});
// Submit empty form
// Both Step 1 inputs are required, so after pressing Submit
// we expect focus to be moved to the first input
$browser->with('@step1', function ($step) {
$step->click('[type=submit]');
$step->assertFocused('#signup_name');
});
// Submit invalid email
// We expect email input to have is-invalid class added, with .invalid-feedback element
$browser->with('@step1', function ($step) use ($browser) {
$step->type('#signup_name', 'Test User');
$step->type('#signup_email', '@test');
$step->click('[type=submit]');
$step->waitFor('#signup_email.is-invalid');
$step->waitFor('#signup_email + .invalid-feedback');
$browser->waitFor('.toast-error');
$browser->click('.toast-error'); // remove the toast
});
// Submit valid data
// We expect error state on email input to be removed, and Step 2 form visible
$browser->with('@step1', function ($step) {
$step->type('#signup_name', 'Test User');
$step->type('#signup_email', 'BrowserSignupTestUser1@kolab.org');
$step->click('[type=submit]');
$step->assertMissing('#signup_email.is-invalid');
$step->assertMissing('#signup_email + .invalid-feedback');
});
$browser->waitUntilMissing('@step2 #signup_code[value=""]');
$browser->waitFor('@step2');
$browser->assertMissing('@step1');
});
}
/**
* Test 2nd Step of the signup process
*
* @depends testSignupStep1
* @return void
*/
public function testSignupStep2()
{
$this->browse(function (Browser $browser) {
$browser->assertVisible('@step2');
// Here we expect one text input, Back and Continue buttons
$browser->with('@step2', function ($step) {
$step->assertVisible('#signup_short_code');
$step->assertFocused('#signup_short_code');
$step->assertVisible('[type=button]');
$step->assertVisible('[type=submit]');
});
// Test Back button functionality
$browser->click('@step2 [type=button]');
$browser->waitFor('@step1');
$browser->assertFocused('@step1 #signup_name');
$browser->assertMissing('@step2');
// Submit valid Step 1 data (again)
$browser->with('@step1', function ($step) {
$step->type('#signup_name', 'Test User');
$step->type('#signup_email', 'BrowserSignupTestUser1@kolab.org');
$step->click('[type=submit]');
});
$browser->waitFor('@step2');
$browser->assertMissing('@step1');
// Submit invalid code
// We expect code input to have is-invalid class added, with .invalid-feedback element
$browser->with('@step2', function ($step) use ($browser) {
$step->type('#signup_short_code', 'XXXXX');
$step->click('[type=submit]');
$browser->waitFor('.toast-error');
$step->assertVisible('#signup_short_code.is-invalid');
$step->assertVisible('#signup_short_code + .invalid-feedback');
$step->assertFocused('#signup_short_code');
$browser->click('.toast-error'); // remove the toast
});
// Submit valid code
// We expect error state on code input to be removed, and Step 3 form visible
$browser->with('@step2', function ($step) {
// Get the code and short_code from database
// FIXME: Find a nice way to read javascript data without using hidden inputs
$code = $step->value('#signup_code');
$this->assertNotEmpty($code);
$code = SignupCode::find($code);
$step->type('#signup_short_code', $code->short_code);
$step->click('[type=submit]');
$step->assertMissing('#signup_short_code.is-invalid');
$step->assertMissing('#signup_short_code + .invalid-feedback');
});
$browser->waitFor('@step3');
$browser->assertMissing('@step2');
});
}
/**
* Test 3rd Step of the signup process
*
* @depends testSignupStep2
* @return void
*/
public function testSignupStep3()
{
$this->browse(function (Browser $browser) {
$browser->assertVisible('@step3');
// Here we expect 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->assertSeeIn('#signup_login + span', '@' . \config('app.domain'));
+ $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_login + span + .invalid-feedback');
+ $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_login + span + .invalid-feedback');
+ $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?
});
}
}
diff --git a/src/tests/Feature/Controller/PasswordResetTest.php b/src/tests/Feature/Controller/PasswordResetTest.php
index 69f2f5e9..a42d9068 100644
--- a/src/tests/Feature/Controller/PasswordResetTest.php
+++ b/src/tests/Feature/Controller/PasswordResetTest.php
@@ -1,328 +1,326 @@
'passwordresettest@' . \config('app.domain')]);
}
/**
* {@inheritDoc}
*
* @return void
*/
public function tearDown(): void
{
User::where('email', 'passwordresettest@' . \config('app.domain'))
->delete();
-
- parent::tearDown();
}
/**
* Test password-reset/init with invalid input
*
* @return void
*/
public function testPasswordResetInitInvalidInput()
{
// Empty input data
$data = [];
$response = $this->post('/api/auth/password-reset/init', $data);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$this->assertArrayHasKey('email', $json['errors']);
// Data with invalid email
$data = [
'email' => '@example.org',
];
$response = $this->post('/api/auth/password-reset/init', $data);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$this->assertArrayHasKey('email', $json['errors']);
// Data with valid but non-existing email
$data = [
'email' => 'non-existing-password-reset@example.org',
];
$response = $this->post('/api/auth/password-reset/init', $data);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$this->assertArrayHasKey('email', $json['errors']);
// Data with valid email af an existing user with no external email
$data = [
'email' => 'passwordresettest@' . \config('app.domain'),
];
$response = $this->post('/api/auth/password-reset/init', $data);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$this->assertArrayHasKey('email', $json['errors']);
}
/**
* Test password-reset/init with valid input
*
* @return array
*/
public function testPasswordResetInitValidInput()
{
Queue::fake();
// Assert that no jobs were pushed...
Queue::assertNothingPushed();
// Add required external email address to user settings
$user = User::where('email', 'passwordresettest@' . \config('app.domain'))->first();
$user->setSetting('external_email', 'ext@email.com');
$data = [
'email' => 'passwordresettest@' . \config('app.domain'),
];
$response = $this->post('/api/auth/password-reset/init', $data);
$json = $response->json();
$response->assertStatus(200);
$this->assertCount(2, $json);
$this->assertSame('success', $json['status']);
$this->assertNotEmpty($json['code']);
// Assert the email sending job was pushed once
Queue::assertPushed(\App\Jobs\PasswordResetEmail::class, 1);
// Assert the job has proper data assigned
Queue::assertPushed(\App\Jobs\PasswordResetEmail::class, function ($job) use ($user, &$code, $json) {
// Access protected property
$reflection = new \ReflectionClass($job);
$code = $reflection->getProperty('code');
$code->setAccessible(true);
$code = $code->getValue($job);
return $code->user->id === $user->id && $code->code == $json['code'];
});
return [
'code' => $code
];
}
/**
* Test password-reset/verify with invalid input
*
* @return void
*/
public function testPasswordResetVerifyInvalidInput()
{
// Empty data
$data = [];
$response = $this->post('/api/auth/password-reset/verify', $data);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json['errors']);
$this->assertArrayHasKey('short_code', $json['errors']);
// Add verification code and required external email address to user settings
$user = User::where('email', 'passwordresettest@' . \config('app.domain'))->first();
$code = new VerificationCode(['mode' => 'password-reset']);
$user->verificationcodes()->save($code);
// Data with existing code but missing short_code
$data = [
'code' => $code->code,
];
$response = $this->post('/api/auth/password-reset/verify', $data);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$this->assertArrayHasKey('short_code', $json['errors']);
// Data with invalid code
$data = [
'short_code' => '123456789',
'code' => $code->code,
];
$response = $this->post('/api/auth/password-reset/verify', $data);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$this->assertArrayHasKey('short_code', $json['errors']);
// TODO: Test expired code
}
/**
* Test password-reset/verify with valid input
*
* @return void
*/
public function testPasswordResetVerifyValidInput()
{
// Add verification code and required external email address to user settings
$user = User::where('email', 'passwordresettest@' . \config('app.domain'))->first();
$code = new VerificationCode(['mode' => 'password-reset']);
$user->verificationcodes()->save($code);
// Data with invalid code
$data = [
'short_code' => $code->short_code,
'code' => $code->code,
];
$response = $this->post('/api/auth/password-reset/verify', $data);
$json = $response->json();
$response->assertStatus(200);
$this->assertCount(1, $json);
$this->assertSame('success', $json['status']);
}
/**
* Test password-reset with invalid input
*
* @return void
*/
public function testPasswordResetInvalidInput()
{
// Empty data
$data = [];
$response = $this->post('/api/auth/password-reset', $data);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$this->assertArrayHasKey('password', $json['errors']);
$user = User::where('email', 'passwordresettest@' . \config('app.domain'))->first();
$code = new VerificationCode(['mode' => 'password-reset']);
$user->verificationcodes()->save($code);
// Data with existing code but missing password
$data = [
'code' => $code->code,
];
$response = $this->post('/api/auth/password-reset', $data);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$this->assertArrayHasKey('password', $json['errors']);
// Data with existing code but wrong password confirmation
$data = [
'code' => $code->code,
'short_code' => $code->short_code,
'password' => 'password',
'password_confirmation' => 'passwrong',
];
$response = $this->post('/api/auth/password-reset', $data);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$this->assertArrayHasKey('password', $json['errors']);
// Data with invalid short code
$data = [
'code' => $code->code,
'short_code' => '123456789',
'password' => 'password',
'password_confirmation' => 'password',
];
$response = $this->post('/api/auth/password-reset', $data);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$this->assertArrayHasKey('short_code', $json['errors']);
}
/**
* Test password reset with valid input
*
* @return void
*/
public function testPasswordResetValidInput()
{
$user = User::where('email', 'passwordresettest@' . \config('app.domain'))->first();
$code = new VerificationCode(['mode' => 'password-reset']);
$user->verificationcodes()->save($code);
$data = [
'password' => 'test',
'password_confirmation' => 'test',
'code' => $code->code,
'short_code' => $code->short_code,
];
$response = $this->post('/api/auth/password-reset', $data);
$json = $response->json();
$response->assertStatus(200);
$this->assertCount(4, $json);
$this->assertSame('success', $json['status']);
$this->assertSame('bearer', $json['token_type']);
$this->assertTrue(!empty($json['expires_in']) && is_int($json['expires_in']) && $json['expires_in'] > 0);
$this->assertNotEmpty($json['access_token']);
// Check if the code has been removed
$this->assertNull(VerificationCode::find($code->code));
// TODO: Check password before and after (?)
// TODO: Check if the access token works
}
}
diff --git a/src/tests/Feature/Controller/SignupTest.php b/src/tests/Feature/Controller/SignupTest.php
index 4249d9e2..5d58c182 100644
--- a/src/tests/Feature/Controller/SignupTest.php
+++ b/src/tests/Feature/Controller/SignupTest.php
@@ -1,404 +1,604 @@
'SignupControllerTest1@' . \config('app.domain')]);
+ // TODO: Some tests depend on existence of individual and group plans,
+ // we should probably create plans here to not depend on that
+ $domain = self::getPublicDomain();
+
+ $user = User::firstOrCreate(['email' => "SignupControllerTest1@$domain"]);
}
/**
* {@inheritDoc}
*
* @return void
*/
public function tearDown(): void
{
- User::where('email', 'signuplogin@' . \config('app.domain'))
- ->orWhere('email', 'SignupControllerTest1@' . \config('app.domain'))
+ $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();
+ }
- parent::tearDown();
+ /**
+ * 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 signup initialization with invalid input
*
* @return void
*/
public function testSignupInitInvalidInput()
{
// Empty input data
$data = [];
$response = $this->post('/api/auth/signup/init', $data);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json['errors']);
$this->assertArrayHasKey('email', $json['errors']);
$this->assertArrayHasKey('name', $json['errors']);
// Data with missing name
$data = [
'email' => 'UsersApiControllerTest1@UsersApiControllerTest.com',
- 'password' => 'simple123',
- 'password_confirmation' => 'simple123'
];
$response = $this->post('/api/auth/signup/init', $data);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$this->assertArrayHasKey('name', $json['errors']);
// Data with invalid email (but not phone number)
$data = [
'email' => '@example.org',
'name' => 'Signup User',
- 'password' => 'simple123',
- 'password_confirmation' => 'simple123'
];
$response = $this->post('/api/auth/signup/init', $data);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$this->assertArrayHasKey('email', $json['errors']);
// TODO: Test phone validation
}
/**
* Test signup initialization with valid input
*
* @return array
*/
public function testSignupInitValidInput()
{
Queue::fake();
// Assert that no jobs were pushed...
Queue::assertNothingPushed();
$data = [
'email' => 'testuser@external.com',
'name' => 'Signup User',
- 'password' => 'simple123',
- 'password_confirmation' => 'simple123'
+ '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(3, $json);
+ $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(2, $json['errors']);
+ $this->assertCount(3, $json['errors']);
$this->assertArrayHasKey('login', $json['errors']);
$this->assertArrayHasKey('password', $json['errors']);
+ $this->assertArrayHasKey('domain', $json['errors']);
- // Passwords do not match
+ $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(1, $json['errors']);
+ $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']);
- // Login invalid
+ // Missing codes
$data = [
- 'login' => 'żżżżż',
+ '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(1, $json['errors']);
- $this->assertArrayHasKey('login', $json['errors']);
+ $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)
{
- $identity = \strtolower('SignupLogin@') . \config('app.domain');
+ $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()
{
- // To access config from dataProvider method we have to refreshApplication() first
- $this->refreshApplication();
- $domain = \config('app.domain');
-
return [
- // general cases (invalid)
- ['', false, 'validation.emailinvalid'],
- ['example.org', false, 'validation.emailinvalid'],
- ['@example.org', false, 'validation.emailinvalid'],
- ['test@localhost', false, 'validation.emailinvalid'],
- // general cases (valid)
- ['test@domain.tld', false, null],
- ['&@example.org', false, null],
- // kolab identity cases
- ['admin@' . $domain, true, 'validation.emailexists'],
- ['administrator@' . $domain, true, 'validation.emailexists'],
- ['sales@' . $domain, true, 'validation.emailexists'],
- ['root@' . $domain, true, 'validation.emailexists'],
- ['&@' . $domain, true, 'validation.emailinvalid'],
- ['testnonsystemdomain@invalid.tld', true, 'validation.emailinvalid'],
- // existing account
- ['SignupControllerTest1@' . $domain, true, 'validation.emailexists'],
- // valid for signup
- ['test.test@' . $domain, true, null],
- ['test_test@' . $domain, true, null],
- ['test-test@' . $domain, true, null],
+ // 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 mostly unit tests, but let's keep it here for now.
+ * 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, $signup, $expected_result)
+ public function testValidateEmail($email, $expected_result)
{
$method = new \ReflectionMethod('App\Http\Controllers\API\SignupController', 'validateEmail');
$method->setAccessible(true);
- $is_phone = false;
- $result = $method->invoke(new SignupController(), $email, $signup);
+ $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/Controller/UsersTest.php b/src/tests/Feature/Controller/UsersTest.php
index 95e46ac9..0bfa5c55 100644
--- a/src/tests/Feature/Controller/UsersTest.php
+++ b/src/tests/Feature/Controller/UsersTest.php
@@ -1,79 +1,77 @@
'UsersControllerTest1@UsersControllerTest.com'
]
);
$user->delete();
}
/**
* {@inheritDoc}
*
* @return void
*/
public function tearDown(): void
{
$user = User::firstOrCreate(
[
'email' => 'UsersControllerTest1@UsersControllerTest.com'
]
);
$user->delete();
-
- parent::tearDown();
}
public function testListUsers()
{
$user = User::firstOrCreate(
[
'email' => 'UsersControllerTest1@UsersControllerTest.com'
]
);
$response = $this->actingAs($user)->get("api/v4/users");
$response->assertJsonCount(1);
$response->assertStatus(200);
}
public function testUserEntitlements()
{
$userA = User::firstOrCreate(
[
'email' => 'UserEntitlement2A@UserEntitlement.com'
]
);
$response = $this->actingAs($userA, 'api')->get("/api/v4/users/{$userA->id}");
$response->assertStatus(200);
$response->assertJson(['id' => $userA->id]);
$user = factory(User::class)->create();
$response = $this->actingAs($user)->get("/api/v4/users/{$userA->id}");
$response->assertStatus(404);
}
}
diff --git a/src/tests/Feature/DomainTest.php b/src/tests/Feature/DomainTest.php
index 553fea35..788941f9 100644
--- a/src/tests/Feature/DomainTest.php
+++ b/src/tests/Feature/DomainTest.php
@@ -1,75 +1,101 @@
first();
- if ($domain) {
- $domain->delete();
- }
+ Domain::where('namespace', $namespace)->delete();
}
+
+ Domain::where('namespace', 'public-active.com')->delete();
}
- public function testDomainStatus()
+ public function testDomainStatus(): void
{
$statuses = [ "new", "active", "confirmed", "suspended", "deleted" ];
$domains = \App\Utils::powerSet($statuses);
foreach ($domains as $namespace_elements) {
$namespace = implode('-', $namespace_elements) . '.com';
$status = 1;
if (in_array("new", $namespace_elements)) {
$status += Domain::STATUS_NEW;
}
if (in_array("active", $namespace_elements)) {
$status += Domain::STATUS_ACTIVE;
}
if (in_array("confirmed", $namespace_elements)) {
$status += Domain::STATUS_CONFIRMED;
}
if (in_array("suspended", $namespace_elements)) {
$status += Domain::STATUS_SUSPENDED;
}
if (in_array("deleted", $namespace_elements)) {
$status += Domain::STATUS_DELETED;
}
$domain = Domain::firstOrCreate(
[
'namespace' => $namespace,
'status' => $status,
'type' => Domain::TYPE_EXTERNAL
]
);
$this->assertTrue($domain->status > 1);
}
}
+
+ /**
+ * Tests getPublicDomains() method
+ */
+ public function testGetPublicDomains(): void
+ {
+ $public_domains = Domain::getPublicDomains();
+
+ $this->assertNotContains('public-active.com', $public_domains);
+
+ Domain::create([
+ 'namespace' => 'public-active.com',
+ 'status' => Domain::STATUS_NEW,
+ 'type' => Domain::TYPE_PUBLIC,
+ ]);
+
+ // Public but non-active domain should not be returned
+ $public_domains = Domain::getPublicDomains();
+ $this->assertNotContains('public-active.com', $public_domains);
+
+ $domain = Domain::where('namespace', 'public-active.com')->first();
+ $domain->status = Domain::STATUS_ACTIVE;
+ $domain->save();
+
+ // Public and active domain should be returned
+ $public_domains = Domain::getPublicDomains();
+ $this->assertContains('public-active.com', $public_domains);
+ }
}
diff --git a/src/tests/Feature/Jobs/PasswordResetEmailTest.php b/src/tests/Feature/Jobs/PasswordResetEmailTest.php
index 5012864d..22afe41d 100644
--- a/src/tests/Feature/Jobs/PasswordResetEmailTest.php
+++ b/src/tests/Feature/Jobs/PasswordResetEmailTest.php
@@ -1,71 +1,69 @@
'PasswordReset@UserAccount.com'
]);
$this->code = new VerificationCode([
'mode' => 'password-reset',
]);
$user->verificationcodes()->save($this->code);
$user->setSettings(['external_email' => 'etx@email.com']);
}
/**
* {@inheritDoc}
*
* @return void
*/
public function tearDown(): void
{
$this->code->user->delete();
-
- parent::tearDown();
}
/**
* Test job handle
*
* @return void
*/
public function testPasswordResetEmailHandle()
{
Mail::fake();
// Assert that no jobs were pushed...
Mail::assertNothingSent();
$job = new PasswordResetEmail($this->code);
$job->handle();
// Assert the email sending job was pushed once
Mail::assertSent(PasswordReset::class, 1);
// Assert the mail was sent to the code's email
Mail::assertSent(PasswordReset::class, function ($mail) {
return $mail->hasTo($this->code->user->getSetting('external_email'));
});
}
}
diff --git a/src/tests/Feature/Jobs/SignupVerificationEmailTest.php b/src/tests/Feature/Jobs/SignupVerificationEmailTest.php
index 4dfbc3bf..18f93723 100644
--- a/src/tests/Feature/Jobs/SignupVerificationEmailTest.php
+++ b/src/tests/Feature/Jobs/SignupVerificationEmailTest.php
@@ -1,67 +1,65 @@
code = SignupCode::create([
'data' => [
'email' => 'SignupVerificationEmailTest1@' . \config('app.domain'),
'name' => "Test Job"
]
]);
}
/**
* {@inheritDoc}
*
* @return void
*/
public function tearDown(): void
{
$this->code->delete();
-
- parent::tearDown();
}
/**
* Test job handle
*
* @return void
*/
public function testSignupVerificationEmailHandle()
{
Mail::fake();
// Assert that no jobs were pushed...
Mail::assertNothingSent();
$job = new SignupVerificationEmail($this->code);
$job->handle();
// Assert the email sending job was pushed once
Mail::assertSent(SignupVerification::class, 1);
// Assert the mail was sent to the code's email
Mail::assertSent(SignupVerification::class, function ($mail) {
return $mail->hasTo($this->code->data['email']);
});
}
}
diff --git a/src/tests/Feature/SkuTest.php b/src/tests/Feature/SkuTest.php
new file mode 100644
index 00000000..ff28f243
--- /dev/null
+++ b/src/tests/Feature/SkuTest.php
@@ -0,0 +1,133 @@
+ 'custom-domain.com',
+ 'status' => Domain::STATUS_NEW,
+ 'type' => Domain::TYPE_EXTERNAL,
+ ]
+ );
+ User::firstOrCreate(
+ ['email' => 'sku-test-user@custom-domain.com']
+ );
+ }
+
+ public function tearDown(): void
+ {
+ User::where('email', 'sku-test-user@custom-domain.com')->delete();
+ Domain::where('namespace', 'custom-domain.com')->delete();
+ }
+
+ public function testRegisterEntitlement()
+ {
+ // TODO: This test depends on seeded SKUs, but probably should not
+
+ $user = User::where('email', 'sku-test-user@custom-domain.com')->first();
+ $domain = Domain::where('namespace', 'custom-domain.com')->first();
+ $wallet = $user->wallets()->first();
+
+ // \App\Handlers\Mailbox SKU
+ // Note, we're testing mailbox SKU before domain SKU as it may potentially fail in that order
+ $sku = Sku::where('title', 'mailbox')->first();
+ $sku->registerEntitlement($user);
+
+ $entitlements = $sku->entitlements()->where('owner_id', $user->id)->get();
+ $wallet->refresh();
+ $balance = -$sku->cost;
+
+ $this->assertCount(1, $entitlements);
+ $this->assertSame($user->id, $entitlements[0]->entitleable_id);
+ $this->assertSame(Handlers\Mailbox::entitleableClass(), $entitlements[0]->entitleable_type);
+ $this->assertEquals($balance, $wallet->balance);
+
+ // \App\Handlers\Domain SKU
+ $sku = Sku::where('title', 'domain')->first();
+ $sku->registerEntitlement($user, [$domain]);
+
+ $entitlements = $sku->entitlements()->where('owner_id', $user->id)->get();
+ $wallet->refresh();
+ $balance -= $sku->cost;
+
+ $this->assertCount(1, $entitlements);
+ $this->assertSame($domain->id, $entitlements[0]->entitleable_id);
+ $this->assertSame(Handlers\Domain::entitleableClass(), $entitlements[0]->entitleable_type);
+ $this->assertEquals($balance, $wallet->balance);
+
+ // \App\Handlers\DomainRegistration SKU
+/*
+ $sku = Sku::where('title', 'domain-registration')->first();
+ $sku->registerEntitlement($user, [$domain]);
+
+ $entitlements = $sku->entitlements()->where('owner_id', $user->id)->get();
+ $wallet->refresh();
+ $balance -= $sku->cost;
+
+ $this->assertCount(1, $entitlements);
+ $this->assertSame($domain->id, $entitlements[0]->entitleable_id);
+ $this->assertSame(Handlers\DomainRegistration::entitleableClass(), $entitlements[0]->entitleable_type);
+ $this->assertEquals($balance, $wallet->balance);
+
+ // \App\Handlers\DomainHosting SKU
+ $sku = Sku::where('title', 'domain-hosting')->first();
+ $sku->registerEntitlement($user, [$domain]);
+
+ $entitlements = $sku->entitlements()->where('owner_id', $user->id)->get();
+ $wallet->refresh();
+ $balance -= $sku->cost;
+
+ $this->assertCount(1, $entitlements);
+ $this->assertSame($domain->id, $entitlements[0]->entitleable_id);
+ $this->assertSame(Handlers\DomainHosting::entitleableClass(), $entitlements[0]->entitleable_type);
+ $this->assertEquals($balance, $wallet->balance);
+*/
+ // \App\Handlers\Groupware SKU
+ $sku = Sku::where('title', 'groupware')->first();
+ $sku->registerEntitlement($user, [$domain]);
+
+ $entitlements = $sku->entitlements()->where('owner_id', $user->id)->get();
+ $wallet->refresh();
+ $balance -= $sku->cost;
+
+ $this->assertCount(1, $entitlements);
+ $this->assertSame($user->id, $entitlements[0]->entitleable_id);
+ $this->assertSame(Handlers\Mailbox::entitleableClass(), $entitlements[0]->entitleable_type);
+ $this->assertEquals($balance, $wallet->balance);
+
+ // \App\Handlers\Storage SKU
+ $sku = Sku::where('title', 'storage')->first();
+ $sku->registerEntitlement($user, [$domain]);
+
+ $entitlements = $sku->entitlements()->where('owner_id', $user->id)->get();
+ $wallet->refresh();
+ $balance -= $sku->cost;
+
+ // For Storage entitlement we expect additional Quota record
+ $quota = Quota::where('user_id', $user->id)->first();
+ $this->assertTrue(!empty($quota));
+ // TODO: This should be a constant and/or config option, and probably
+ // quota should not be in bytes
+ $this->assertSame(2147483648, $quota->value);
+
+ $this->assertCount(1, $entitlements);
+ $this->assertSame($quota->id, $entitlements[0]->entitleable_id);
+ $this->assertSame(Handlers\Storage::entitleableClass(), $entitlements[0]->entitleable_type);
+ $this->assertEquals($balance, $wallet->balance);
+ }
+}
diff --git a/src/tests/Feature/VerificationCodeTest.php b/src/tests/Feature/VerificationCodeTest.php
index 3a179618..4c9bc8ce 100644
--- a/src/tests/Feature/VerificationCodeTest.php
+++ b/src/tests/Feature/VerificationCodeTest.php
@@ -1,46 +1,44 @@
'UserAccountA@UserAccount.com']);
$data = [
'user_id' => $user->id,
'mode' => 'password-reset',
];
$now = new \DateTime('now');
$code = VerificationCode::create($data);
$code_length = env('VERIFICATION_CODE_LENGTH', VerificationCode::SHORTCODE_LENGTH);
$code_exp_hrs = env('VERIFICATION_CODE_EXPIRY', VerificationCode::CODE_EXP_HOURS);
$this->assertFalse($code->isExpired());
$this->assertTrue(strlen($code->code) === VerificationCode::CODE_LENGTH);
$this->assertTrue(strlen($code->short_code) === $code_length);
$this->assertSame($data['mode'], $code->mode);
$this->assertSame($user->id, $code->user->id);
$this->assertInstanceOf(\DateTime::class, $code->expires_at);
$this->assertSame($code_exp_hrs, $code->expires_at->diff($now)->h + 1);
$inst = VerificationCode::find($code->code);
$this->assertInstanceOf(VerificationCode::class, $inst);
$this->assertSame($inst->code, $code->code);
}
}
diff --git a/src/tests/Feature/WalletTest.php b/src/tests/Feature/WalletTest.php
index 7f71a64f..0c82314b 100644
--- a/src/tests/Feature/WalletTest.php
+++ b/src/tests/Feature/WalletTest.php
@@ -1,242 +1,226 @@
users as $user) {
$_user = User::firstOrCreate(['email' => $user]);
$_user->delete();
}
}
public function tearDown(): void
{
foreach ($this->users as $user) {
$_user = User::firstOrCreate(['email' => $user]);
$_user->delete();
}
-
- parent::tearDown();
}
/**
- Verify a wallet is created, when a user is created.
-
- @return void
+ * Verify a wallet is created, when a user is created.
*/
- public function testCreateUserCreatesWallet()
+ public function testCreateUserCreatesWallet(): void
{
$user = User::firstOrCreate(
[
'email' => 'UserWallet1@UserWallet.com'
]
);
$this->assertTrue($user->wallets()->count() == 1);
}
/**
- Verify a user can haz more wallets.
-
- @return void
+ * Verify a user can haz more wallets.
*/
- public function testAddWallet()
+ public function testAddWallet(): void
{
$user = User::firstOrCreate(
[
'email' => 'UserWallet2@UserWallet.com'
]
);
$user->wallets()->save(
new Wallet(['currency' => 'USD'])
);
$this->assertTrue($user->wallets()->count() >= 2);
$user->wallets()->each(
function ($wallet) {
$this->assertTrue($wallet->balance === 0.00);
}
);
}
/**
- Verify we can not delete a user wallet that holds balance.
-
- @return void
+ * Verify we can not delete a user wallet that holds balance.
*/
- public function testDeleteWalletWithCredit()
+ public function testDeleteWalletWithCredit(): void
{
$user = User::firstOrCreate(
[
'email' => 'UserWallet3@UserWallet.com'
]
);
$user->wallets()->each(
function ($wallet) {
$wallet->credit(1.00)->save();
}
);
$user->wallets()->each(
function ($wallet) {
$this->assertFalse($wallet->delete());
}
);
}
/**
- Verify we can not delete a wallet that is the last wallet.
-
- @return void
+ * Verify we can not delete a wallet that is the last wallet.
*/
- public function testDeleteLastWallet()
+ public function testDeleteLastWallet(): void
{
$user = User::firstOrCreate(
[
'email' => 'UserWallet4@UserWallet.com'
]
);
$this->assertTrue($user->wallets()->count() == 1);
$user->wallets()->each(
function ($wallet) {
$this->assertFalse($wallet->delete());
}
);
}
/**
- Verify we can remove a wallet that is an additional wallet.
-
- @return void
+ * Verify we can remove a wallet that is an additional wallet.
*/
- public function testDeleteAddtWallet()
+ public function testDeleteAddtWallet(): void
{
$user = User::firstOrCreate(
[
'email' => 'UserWallet5@UserWallet.com'
]
);
$user->wallets()->save(
new Wallet(['currency' => 'USD'])
);
$user->wallets()->each(
function ($wallet) {
if ($wallet->currency == 'USD') {
$this->assertNotFalse($wallet->delete());
}
}
);
}
/**
- Verify a wallet can be assigned a controller.
-
- @return void
+ * Verify a wallet can be assigned a controller.
*/
- public function testAddWalletController()
+ public function testAddWalletController(): void
{
$userA = User::firstOrCreate(
[
'email' => 'WalletControllerA@WalletController.com'
]
);
$userA->wallets()->each(
function ($wallet) {
$userB = User::firstOrCreate(
[
'email' => 'WalletControllerB@WalletController.com'
]
);
$wallet->addController($userB);
}
);
$userB = User::firstOrCreate(
[
'email' => 'WalletControllerB@WalletController.com'
]
);
$this->assertTrue($userB->accounts()->count() == 1);
$aWallet = $userA->wallets()->get();
$bAccount = $userB->accounts()->get();
$this->assertTrue($bAccount[0]->id === $aWallet[0]->id);
}
/**
- Verify controllers can also be removed from wallets.
-
- @return void
+ * Verify controllers can also be removed from wallets.
*/
- public function testRemoveWalletController()
+ public function testRemoveWalletController(): void
{
$userA = User::firstOrCreate(
[
'email' => 'WalletController2A@WalletController.com'
]
);
$userA->wallets()->each(
function ($wallet) {
$userB = User::firstOrCreate(
[
'email' => 'WalletController2B@WalletController.com'
]
);
$wallet->addController($userB);
}
);
$userB = User::firstOrCreate(
[
'email' => 'WalletController2B@WalletController.com'
]
);
$userB->accounts()->each(
function ($wallet) {
$userB = User::firstOrCreate(
[
'email' => 'WalletController2B@WalletController.com'
]
);
$wallet->removeController($userB);
}
);
$this->assertTrue($userB->accounts()->count() == 0);
}
}