diff --git a/src/app/Http/Controllers/API/V4/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php
index 2d441230..8cc251b4 100644
--- a/src/app/Http/Controllers/API/V4/UsersController.php
+++ b/src/app/Http/Controllers/API/V4/UsersController.php
@@ -1,821 +1,822 @@
errorResponse(404);
}
// User can't remove himself until he's the controller
if (!$this->guard()->user()->canDelete($user)) {
return $this->errorResponse(403);
}
$user->delete();
return response()->json([
'status' => 'success',
'message' => __('app.user-delete-success'),
]);
}
/**
* Listing of users.
*
* The user-entitlements billed to the current user wallet(s)
*
* @return \Illuminate\Http\JsonResponse
*/
public function index()
{
$user = $this->guard()->user();
$result = $user->users()->orderBy('email')->get()->map(function ($user) {
$data = $user->toArray();
$data = array_merge($data, self::userStatuses($user));
return $data;
});
return response()->json($result);
}
/**
* Display information on the user account specified by $id.
*
* @param int $id The account to show information for.
*
* @return \Illuminate\Http\JsonResponse
*/
public function show($id)
{
$user = User::find($id);
if (empty($user)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canRead($user)) {
return $this->errorResponse(403);
}
$response = $this->userResponse($user);
// Simplified Entitlement/SKU information,
// TODO: I agree this format may need to be extended in future
$response['skus'] = [];
foreach ($user->entitlements as $ent) {
$sku = $ent->sku;
- $response['skus'][$sku->id] = [
-// 'cost' => $ent->cost,
- 'count' => isset($response['skus'][$sku->id]) ? $response['skus'][$sku->id]['count'] + 1 : 1,
- ];
+ if (!isset($response['skus'][$sku->id])) {
+ $response['skus'][$sku->id] = ['costs' => [], 'count' => 0];
+ }
+ $response['skus'][$sku->id]['count']++;
+ $response['skus'][$sku->id]['costs'][] = $ent->cost;
}
return response()->json($response);
}
/**
* Fetch user status (and reload setup process)
*
* @param int $id User identifier
*
* @return \Illuminate\Http\JsonResponse
*/
public function status($id)
{
$user = User::find($id);
if (empty($user)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canRead($user)) {
return $this->errorResponse(403);
}
$response = self::statusInfo($user);
if (!empty(request()->input('refresh'))) {
$updated = false;
$async = false;
$last_step = 'none';
foreach ($response['process'] as $idx => $step) {
$last_step = $step['label'];
if (!$step['state']) {
$exec = $this->execProcessStep($user, $step['label']);
if (!$exec) {
if ($exec === null) {
$async = true;
}
break;
}
$updated = true;
}
}
if ($updated) {
$response = self::statusInfo($user);
}
$success = $response['isReady'];
$suffix = $success ? 'success' : 'error-' . $last_step;
$response['status'] = $success ? 'success' : 'error';
$response['message'] = \trans('app.process-' . $suffix);
if ($async && !$success) {
$response['processState'] = 'waiting';
$response['status'] = 'success';
$response['message'] = \trans('app.process-async');
}
}
$response = array_merge($response, self::userStatuses($user));
return response()->json($response);
}
/**
* User status (extended) information
*
* @param \App\User $user User object
*
* @return array Status information
*/
public static function statusInfo(User $user): array
{
$process = [];
$steps = [
'user-new' => true,
'user-ldap-ready' => $user->isLdapReady(),
'user-imap-ready' => $user->isImapReady(),
];
// Create a process check list
foreach ($steps as $step_name => $state) {
$step = [
'label' => $step_name,
'title' => \trans("app.process-{$step_name}"),
'state' => $state,
];
$process[] = $step;
}
list ($local, $domain) = explode('@', $user->email);
$domain = Domain::where('namespace', $domain)->first();
// If that is not a public domain, add domain specific steps
if ($domain && !$domain->isPublic()) {
$domain_status = DomainsController::statusInfo($domain);
$process = array_merge($process, $domain_status['process']);
}
$all = count($process);
$checked = count(array_filter($process, function ($v) {
return $v['state'];
}));
$state = $all === $checked ? 'done' : 'running';
// After 180 seconds assume the process is in failed state,
// this should unlock the Refresh button in the UI
if ($all !== $checked && $user->created_at->diffInSeconds(Carbon::now()) > 180) {
$state = 'failed';
}
// Check if the user is a controller of his wallet
$isController = $user->canDelete($user);
$hasCustomDomain = $user->wallet()->entitlements()
->where('entitleable_type', Domain::class)
->count() > 0;
// Get user's entitlements titles
$skus = $user->entitlements()->select('skus.title')
->join('skus', 'skus.id', '=', 'entitlements.sku_id')
->get()
->pluck('title')
->sort()
->unique()
->values()
->all();
return [
'skus' => $skus,
// TODO: This will change when we enable all users to create domains
'enableDomains' => $isController && $hasCustomDomain,
'enableUsers' => $isController,
'enableWallets' => $isController,
'process' => $process,
'processState' => $state,
'isReady' => $all === $checked,
];
}
/**
* Create a new user record.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function store(Request $request)
{
$current_user = $this->guard()->user();
$owner = $current_user->wallet()->owner;
if ($owner->id != $current_user->id) {
return $this->errorResponse(403);
}
$this->deleteBeforeCreate = null;
if ($error_response = $this->validateUserRequest($request, null, $settings)) {
return $error_response;
}
if (empty($request->package) || !($package = \App\Package::find($request->package))) {
$errors = ['package' => \trans('validation.packagerequired')];
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
if ($package->isDomain()) {
$errors = ['package' => \trans('validation.packageinvalid')];
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
DB::beginTransaction();
// @phpstan-ignore-next-line
if ($this->deleteBeforeCreate) {
$this->deleteBeforeCreate->forceDelete();
}
// Create user record
$user = User::create([
'email' => $request->email,
'password' => $request->password,
]);
$owner->assignPackage($package, $user);
if (!empty($settings)) {
$user->setSettings($settings);
}
if (!empty($request->aliases)) {
$user->setAliases($request->aliases);
}
DB::commit();
return response()->json([
'status' => 'success',
'message' => __('app.user-create-success'),
]);
}
/**
* Update user data.
*
* @param \Illuminate\Http\Request $request The API request.
* @param string $id User identifier
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function update(Request $request, $id)
{
$user = User::find($id);
if (empty($user)) {
return $this->errorResponse(404);
}
$current_user = $this->guard()->user();
// TODO: Decide what attributes a user can change on his own profile
if (!$current_user->canUpdate($user)) {
return $this->errorResponse(403);
}
if ($error_response = $this->validateUserRequest($request, $user, $settings)) {
return $error_response;
}
// Entitlements, only controller can do that
if ($request->skus !== null && !$current_user->canDelete($user)) {
return $this->errorResponse(422, "You have no permission to change entitlements");
}
DB::beginTransaction();
$this->updateEntitlements($user, $request->skus);
if (!empty($settings)) {
$user->setSettings($settings);
}
if (!empty($request->password)) {
$user->password = $request->password;
$user->save();
}
if (isset($request->aliases)) {
$user->setAliases($request->aliases);
}
// TODO: Make sure that UserUpdate job is created in case of entitlements update
// and no password change. So, for example quota change is applied to LDAP
// TODO: Review use of $user->save() in the above context
DB::commit();
$response = [
'status' => 'success',
'message' => __('app.user-update-success'),
];
// For self-update refresh the statusInfo in the UI
if ($user->id == $current_user->id) {
$response['statusInfo'] = self::statusInfo($user);
}
return response()->json($response);
}
/**
* Get the guard to be used during authentication.
*
* @return \Illuminate\Contracts\Auth\Guard
*/
public function guard()
{
return Auth::guard();
}
/**
* Update user entitlements.
*
* @param \App\User $user The user
* @param array $rSkus List of SKU IDs requested for the user in the form [id=>qty]
*/
protected function updateEntitlements(User $user, $rSkus)
{
if (!is_array($rSkus)) {
return;
}
// list of skus, [id=>obj]
$skus = Sku::all()->mapWithKeys(
function ($sku) {
return [$sku->id => $sku];
}
);
// existing entitlement's SKUs
$eSkus = [];
$user->entitlements()->groupBy('sku_id')
->selectRaw('count(*) as total, sku_id')->each(
function ($e) use (&$eSkus) {
$eSkus[$e->sku_id] = $e->total;
}
);
foreach ($skus as $skuID => $sku) {
$e = array_key_exists($skuID, $eSkus) ? $eSkus[$skuID] : 0;
$r = array_key_exists($skuID, $rSkus) ? $rSkus[$skuID] : 0;
if ($sku->handler_class == \App\Handlers\Mailbox::class) {
if ($r != 1) {
throw new \Exception("Invalid quantity of mailboxes");
}
}
if ($e > $r) {
// remove those entitled more than existing
$user->removeSku($sku, ($e - $r));
} elseif ($e < $r) {
// add those requested more than entitled
$user->assignSku($sku, ($r - $e));
}
}
}
/**
* Create a response data array for specified user.
*
* @param \App\User $user User object
*
* @return array Response data
*/
public static function userResponse(User $user): array
{
$response = $user->toArray();
// Settings
$response['settings'] = [];
foreach ($user->settings()->whereIn('key', self::USER_SETTINGS)->get() as $item) {
$response['settings'][$item->key] = $item->value;
}
// Aliases
$response['aliases'] = [];
foreach ($user->aliases as $item) {
$response['aliases'][] = $item->alias;
}
// Status info
$response['statusInfo'] = self::statusInfo($user);
$response = array_merge($response, self::userStatuses($user));
// Add more info to the wallet object output
$map_func = function ($wallet) use ($user) {
$result = $wallet->toArray();
if ($wallet->discount) {
$result['discount'] = $wallet->discount->discount;
$result['discount_description'] = $wallet->discount->description;
}
if ($wallet->user_id != $user->id) {
$result['user_email'] = $wallet->owner->email;
}
$provider = \App\Providers\PaymentProvider::factory($wallet);
$result['provider'] = $provider->name();
return $result;
};
// Information about wallets and accounts for access checks
$response['wallets'] = $user->wallets->map($map_func)->toArray();
$response['accounts'] = $user->accounts->map($map_func)->toArray();
$response['wallet'] = $map_func($user->wallet());
return $response;
}
/**
* Prepare user statuses for the UI
*
* @param \App\User $user User object
*
* @return array Statuses array
*/
protected static function userStatuses(User $user): array
{
return [
'isImapReady' => $user->isImapReady(),
'isLdapReady' => $user->isLdapReady(),
'isSuspended' => $user->isSuspended(),
'isActive' => $user->isActive(),
'isDeleted' => $user->isDeleted() || $user->trashed(),
];
}
/**
* Validate user input
*
* @param \Illuminate\Http\Request $request The API request.
* @param \App\User|null $user User identifier
* @param array $settings User settings (from the request)
*
* @return \Illuminate\Http\JsonResponse|null The error response on error
*/
protected function validateUserRequest(Request $request, $user, &$settings = [])
{
$rules = [
'external_email' => 'nullable|email',
'phone' => 'string|nullable|max:64|regex:/^[0-9+() -]+$/',
'first_name' => 'string|nullable|max:128',
'last_name' => 'string|nullable|max:128',
'organization' => 'string|nullable|max:512',
'billing_address' => 'string|nullable|max:1024',
'country' => 'string|nullable|alpha|size:2',
'currency' => 'string|nullable|alpha|size:3',
'aliases' => 'array|nullable',
];
if (empty($user) || !empty($request->password) || !empty($request->password_confirmation)) {
$rules['password'] = 'required|min:4|max:2048|confirmed';
}
$errors = [];
// Validate input
$v = Validator::make($request->all(), $rules);
if ($v->fails()) {
$errors = $v->errors()->toArray();
}
$controller = $user ? $user->wallet()->owner : $this->guard()->user();
// For new user validate email address
if (empty($user)) {
$email = $request->email;
if (empty($email)) {
$errors['email'] = \trans('validation.required', ['attribute' => 'email']);
} elseif ($error = self::validateEmail($email, $controller, $this->deleteBeforeCreate)) {
$errors['email'] = $error;
}
}
// Validate aliases input
if (isset($request->aliases)) {
$aliases = [];
$existing_aliases = $user ? $user->aliases()->get()->pluck('alias')->toArray() : [];
foreach ($request->aliases as $idx => $alias) {
if (is_string($alias) && !empty($alias)) {
// Alias cannot be the same as the email address (new user)
if (!empty($email) && Str::lower($alias) == Str::lower($email)) {
continue;
}
// validate new aliases
if (
!in_array($alias, $existing_aliases)
&& ($error = self::validateAlias($alias, $controller))
) {
if (!isset($errors['aliases'])) {
$errors['aliases'] = [];
}
$errors['aliases'][$idx] = $error;
continue;
}
$aliases[] = $alias;
}
}
$request->aliases = $aliases;
}
if (!empty($errors)) {
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
// Update user settings
$settings = $request->only(array_keys($rules));
unset($settings['password'], $settings['aliases'], $settings['email']);
return null;
}
/**
* Execute (synchronously) specified step in a user setup process.
*
* @param \App\User $user User object
* @param string $step Step identifier (as in self::statusInfo())
*
* @return bool|null True if the execution succeeded, False if not, Null when
* the job has been sent to the worker (result unknown)
*/
public static function execProcessStep(User $user, string $step): ?bool
{
try {
if (strpos($step, 'domain-') === 0) {
list ($local, $domain) = explode('@', $user->email);
$domain = Domain::where('namespace', $domain)->first();
return DomainsController::execProcessStep($domain, $step);
}
switch ($step) {
case 'user-ldap-ready':
// User not in LDAP, create it
$job = new \App\Jobs\User\CreateJob($user->id);
$job->handle();
$user->refresh();
return $user->isLdapReady();
case 'user-imap-ready':
// User not in IMAP? Verify again
// Do it synchronously if the imap admin credentials are available
// otherwise let the worker do the job
if (!\config('imap.admin_password')) {
\App\Jobs\User\VerifyJob::dispatch($user->id);
return null;
}
$job = new \App\Jobs\User\VerifyJob($user->id);
$job->handle();
$user->refresh();
return $user->isImapReady();
}
} catch (\Exception $e) {
\Log::error($e);
}
return false;
}
/**
* Email address validation for use as a user mailbox (login).
*
* @param string $email Email address
* @param \App\User $user The account owner
* @param null|\App\User|\App\Group $deleted Filled with an instance of a deleted user or group
* with the specified email address, if exists
*
* @return ?string Error message on validation error
*/
public static function validateEmail(string $email, \App\User $user, &$deleted = null): ?string
{
$deleted = null;
if (strpos($email, '@') === false) {
return \trans('validation.entryinvalid', ['attribute' => 'email']);
}
list($login, $domain) = explode('@', Str::lower($email));
if (strlen($login) === 0 || strlen($domain) === 0) {
return \trans('validation.entryinvalid', ['attribute' => 'email']);
}
// Check if domain exists
$domain = Domain::where('namespace', $domain)->first();
if (empty($domain)) {
return \trans('validation.domaininvalid');
}
// Validate login part alone
$v = Validator::make(
['email' => $login],
['email' => ['required', new UserEmailLocal(!$domain->isPublic())]]
);
if ($v->fails()) {
return $v->errors()->toArray()['email'][0];
}
// Check if it is one of domains available to the user
$domains = \collect($user->domains())->pluck('namespace')->all();
if (!in_array($domain->namespace, $domains)) {
return \trans('validation.entryexists', ['attribute' => 'domain']);
}
// Check if a user with specified address already exists
if ($existing_user = User::emailExists($email, true)) {
// If this is a deleted user in the same custom domain
// we'll force delete him before
if (!$domain->isPublic() && $existing_user->trashed()) {
$deleted = $existing_user;
} else {
return \trans('validation.entryexists', ['attribute' => 'email']);
}
}
// Check if an alias with specified address already exists.
if (User::aliasExists($email)) {
return \trans('validation.entryexists', ['attribute' => 'email']);
}
// Check if a group with specified address already exists
if ($existing_group = Group::emailExists($email, true)) {
// If this is a deleted group in the same custom domain
// we'll force delete it before
if (!$domain->isPublic() && $existing_group->trashed()) {
$deleted = $existing_group;
} else {
return \trans('validation.entryexists', ['attribute' => 'email']);
}
}
return null;
}
/**
* Email address validation for use as an alias.
*
* @param string $email Email address
* @param \App\User $user The account owner
*
* @return ?string Error message on validation error
*/
public static function validateAlias(string $email, \App\User $user): ?string
{
if (strpos($email, '@') === false) {
return \trans('validation.entryinvalid', ['attribute' => 'alias']);
}
list($login, $domain) = explode('@', Str::lower($email));
if (strlen($login) === 0 || strlen($domain) === 0) {
return \trans('validation.entryinvalid', ['attribute' => 'alias']);
}
// Check if domain exists
$domain = Domain::where('namespace', $domain)->first();
if (empty($domain)) {
return \trans('validation.domaininvalid');
}
// Validate login part alone
$v = Validator::make(
['alias' => $login],
['alias' => ['required', new UserEmailLocal(!$domain->isPublic())]]
);
if ($v->fails()) {
return $v->errors()->toArray()['alias'][0];
}
// Check if it is one of domains available to the user
$domains = \collect($user->domains())->pluck('namespace')->all();
if (!in_array($domain->namespace, $domains)) {
return \trans('validation.entryexists', ['attribute' => 'domain']);
}
// Check if a user with specified address already exists
if ($existing_user = User::emailExists($email, true)) {
// Allow an alias in a custom domain to an address that was a user before
if ($domain->isPublic() || !$existing_user->trashed()) {
return \trans('validation.entryexists', ['attribute' => 'alias']);
}
}
// Check if an alias with specified address already exists
if (User::aliasExists($email)) {
// Allow assigning the same alias to a user in the same group account,
// but only for non-public domains
if ($domain->isPublic()) {
return \trans('validation.entryexists', ['attribute' => 'alias']);
}
}
// Check if a group with specified address already exists
if (Group::emailExists($email)) {
return \trans('validation.entryexists', ['attribute' => 'alias']);
}
return null;
}
}
diff --git a/src/resources/js/app.js b/src/resources/js/app.js
index f744f992..cce4fa10 100644
--- a/src/resources/js/app.js
+++ b/src/resources/js/app.js
@@ -1,477 +1,473 @@
/**
* First we will load all of this project's JavaScript dependencies which
* includes Vue and other libraries. It is a great starting point when
* building robust, powerful web applications using Vue and Laravel.
*/
require('./bootstrap')
import AppComponent from '../vue/App'
import MenuComponent from '../vue/Widgets/Menu'
import SupportForm from '../vue/Widgets/SupportForm'
import store from './store'
const loader = '
Loading
'
let isLoading = 0
// Lock the UI with the 'loading...' element
const startLoading = () => {
isLoading++
let loading = $('#app > .app-loader').removeClass('fadeOut')
if (!loading.length) {
$('#app').append($(loader))
}
}
// Hide "loading" overlay
const stopLoading = () => {
if (isLoading > 0) {
$('#app > .app-loader').addClass('fadeOut')
isLoading--;
}
}
let loadingRoute
// Note: This has to be before the app is created
// Note: You cannot use app inside of the function
window.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) {
// remember the original request, to use after login
store.state.afterLogin = to;
// redirect to login page
next({ name: 'login' })
return
}
if (to.meta.loading) {
startLoading()
loadingRoute = to.name
}
next()
})
window.router.afterEach((to, from) => {
if (to.name && loadingRoute === to.name) {
stopLoading()
loadingRoute = null
}
// When changing a page remove old:
// - error page
// - modal backdrop
$('#error-page,.modal-backdrop.show').remove()
})
const app = new Vue({
el: '#app',
components: {
AppComponent,
MenuComponent,
},
store,
router: window.router,
data() {
return {
isAdmin: window.isAdmin,
appName: window.config['app.name'],
appUrl: window.config['app.url'],
themeDir: '/themes/' + window.config['app.theme']
}
},
methods: {
// Clear (bootstrap) form validation state
clearFormValidation(form) {
$(form).find('.is-invalid').removeClass('is-invalid')
$(form).find('.invalid-feedback').remove()
},
hasRoute(name) {
return this.$router.resolve({ name: name }).resolved.matched.length > 0
},
hasSKU(name) {
const authInfo = store.state.authInfo
return authInfo.statusInfo.skus && authInfo.statusInfo.skus.indexOf(name) != -1
},
isController(wallet_id) {
if (wallet_id && store.state.authInfo) {
let i
for (i = 0; i < store.state.authInfo.wallets.length; i++) {
if (wallet_id == store.state.authInfo.wallets[i].id) {
return true
}
}
for (i = 0; i < store.state.authInfo.accounts.length; i++) {
if (wallet_id == store.state.authInfo.accounts[i].id) {
return true
}
}
}
return false
},
// Set user state to "logged in"
loginUser(response, dashboard, update) {
if (!update) {
store.commit('logoutUser') // destroy old state data
store.commit('loginUser')
}
localStorage.setItem('token', response.access_token)
axios.defaults.headers.common.Authorization = 'Bearer ' + response.access_token
if (response.email) {
store.state.authInfo = response
}
if (dashboard !== false) {
this.$router.push(store.state.afterLogin || { name: 'dashboard' })
}
store.state.afterLogin = null
// Refresh the token before it expires
let timeout = response.expires_in || 0
// We'll refresh 60 seconds before the token expires
if (timeout > 60) {
timeout -= 60
}
// TODO: We probably should try a few times in case of an error
// TODO: We probably should prevent axios from doing any requests
// while the token is being refreshed
this.refreshTimeout = setTimeout(() => {
axios.post('/api/auth/refresh').then(response => {
this.loginUser(response.data, false, true)
})
}, timeout * 1000)
},
// Set user state to "not logged in"
logoutUser(redirect) {
store.commit('logoutUser')
localStorage.setItem('token', '')
delete axios.defaults.headers.common.Authorization
if (redirect !== false) {
this.$router.push({ name: 'login' })
}
clearTimeout(this.refreshTimeout)
},
logo(mode) {
let src = this.appUrl + this.themeDir + '/images/logo_' + (mode || 'header') + '.png'
return ``
},
// Display "loading" overlay inside of the specified element
addLoader(elem, small = true) {
$(elem).css({position: 'relative'}).append(small ? $(loader).addClass('small') : $(loader))
},
// Remove loader element added in addLoader()
removeLoader(elem) {
$(elem).find('.app-loader').remove()
},
startLoading,
stopLoading,
isLoading() {
return isLoading > 0
},
errorPage(code, msg) {
// Until https://github.com/vuejs/vue-router/issues/977 is implemented
// we can't really use router to display error page as it has two side
// effects: it changes the URL and adds the error page to browser history.
// For now we'll be replacing current view with error page "manually".
const map = {
400: "Bad request",
401: "Unauthorized",
403: "Access denied",
404: "Not found",
405: "Method not allowed",
500: "Internal server error"
}
if (!msg) msg = map[code] || "Unknown Error"
const error_page = `
${code}
${msg}
`
$('#error-page').remove()
$('#app').append(error_page)
app.updateBodyClass('error')
},
errorHandler(error) {
this.stopLoading()
if (!error.response) {
// TODO: probably network connection error
} else if (error.response.status === 401) {
// Remember requested route to come back to it after log in
if (this.$route.meta.requiresAuth) {
store.state.afterLogin = this.$route
this.logoutUser()
} else {
this.logoutUser(false)
}
} else {
this.errorPage(error.response.status, error.response.statusText)
}
},
downloadFile(url) {
// TODO: This might not be a best way for big files as the content
// will be stored (temporarily) in browser memory
// TODO: This method does not show the download progress in the browser
// but it could be implemented in the UI, axios has 'progress' property
axios.get(url, { responseType: 'blob' })
.then(response => {
const link = document.createElement('a')
const contentDisposition = response.headers['content-disposition']
let filename = 'unknown'
if (contentDisposition) {
const match = contentDisposition.match(/filename="(.+)"/);
if (match.length === 2) {
filename = match[1];
}
}
link.href = window.URL.createObjectURL(response.data)
link.download = filename
link.click()
})
},
price(price, currency) {
return ((price || 0) / 100).toLocaleString('de-DE', { style: 'currency', currency: currency || 'CHF' })
},
- priceLabel(cost, units = 1, discount) {
+ priceLabel(cost, discount) {
let index = ''
- if (units < 0) {
- units = 1
- }
-
if (discount) {
cost = Math.floor(cost * ((100 - discount) / 100))
index = '\u00B9'
}
- return this.price(cost * units) + '/month' + index
+ return this.price(cost) + '/month' + index
},
clickRecord(event) {
if (!/^(a|button|svg|path)$/i.test(event.target.nodeName)) {
let link = $(event.target).closest('tr').find('a')[0]
if (link) {
link.click()
}
}
},
domainStatusClass(domain) {
if (domain.isDeleted) {
return 'text-muted'
}
if (domain.isSuspended) {
return 'text-warning'
}
if (!domain.isVerified || !domain.isLdapReady || !domain.isConfirmed) {
return 'text-danger'
}
return 'text-success'
},
domainStatusText(domain) {
if (domain.isDeleted) {
return 'Deleted'
}
if (domain.isSuspended) {
return 'Suspended'
}
if (!domain.isVerified || !domain.isLdapReady || !domain.isConfirmed) {
return 'Not Ready'
}
return 'Active'
},
pageName(path) {
let page = this.$route.path
// check if it is a "menu page", find the page name
// otherwise we'll use the real path as page name
window.config.menu.every(item => {
if (item.location == page && item.page) {
page = item.page
return false
}
})
page = page.replace(/^\//, '')
return page ? page : '404'
},
supportDialog(container) {
let dialog = $('#support-dialog')
// FIXME: Find a nicer way of doing this
if (!dialog.length) {
let form = new Vue(SupportForm)
form.$mount($('
').appendTo(container)[0])
form.$root = this
form.$toast = this.$toast
dialog = $(form.$el)
}
dialog.on('shown.bs.modal', () => {
dialog.find('input').first().focus()
}).modal()
},
userStatusClass(user) {
if (user.isDeleted) {
return 'text-muted'
}
if (user.isSuspended) {
return 'text-warning'
}
if (!user.isImapReady || !user.isLdapReady) {
return 'text-danger'
}
return 'text-success'
},
userStatusText(user) {
if (user.isDeleted) {
return 'Deleted'
}
if (user.isSuspended) {
return 'Suspended'
}
if (!user.isImapReady || !user.isLdapReady) {
return 'Not Ready'
}
return 'Active'
},
updateBodyClass(name) {
// Add 'class' attribute to the body, different for each page
// so, we can apply page-specific styles
let className = 'page-' + (name || this.pageName()).replace(/\/.*$/, '')
$(document.body).removeClass().addClass(className)
}
}
})
// Add a axios request interceptor
window.axios.interceptors.request.use(
config => {
// This is the only way I found to change configuration options
// on a running application. We need this for browser testing.
config.headers['X-Test-Payment-Provider'] = window.config.paymentProvider
return config
},
error => {
// Do something with request error
return Promise.reject(error)
}
)
// Add a axios response interceptor for general/validation error handler
window.axios.interceptors.response.use(
response => {
if (response.config.onFinish) {
response.config.onFinish()
}
return response
},
error => {
let error_msg
let status = error.response ? error.response.status : 200
// Do not display the error in a toast message, pass the error as-is
if (error.config.ignoreErrors) {
return Promise.reject(error)
}
if (error.config.onFinish) {
error.config.onFinish()
}
if (error.response && status == 422) {
error_msg = "Form validation error"
const modal = $('div.modal.show')
$(modal.length ? modal : 'form').each((i, form) => {
form = $(form)
$.each(error.response.data.errors || {}, (idx, msg) => {
const input_name = (form.data('validation-prefix') || form.find('form').first().data('validation-prefix') || '') + idx
let input = form.find('#' + input_name)
if (!input.length) {
input = form.find('[name="' + input_name + '"]');
}
if (input.length) {
// Create an error message\
// API responses can use a string, array or object
let msg_text = ''
if ($.type(msg) !== 'string') {
$.each(msg, (index, str) => {
msg_text += str + ' '
})
}
else {
msg_text = msg
}
let feedback = $('
').text(msg_text)
if (input.is('.list-input')) {
// List input widget
input.children(':not(:first-child)').each((index, element) => {
if (msg[index]) {
$(element).find('input').addClass('is-invalid')
}
})
input.addClass('is-invalid').next('.invalid-feedback').remove()
input.after(feedback)
}
else {
// Standard form element
input.addClass('is-invalid')
input.parent().find('.invalid-feedback').remove()
input.parent().append(feedback)
}
}
})
form.find('.is-invalid:not(.listinput-widget)').first().focus()
})
}
else if (error.response && error.response.data) {
error_msg = error.response.data.message
}
else {
error_msg = error.request ? error.request.statusText : error.message
}
app.$toast.error(error_msg || "Server Error")
// Pass the error as-is
return Promise.reject(error)
}
)
diff --git a/src/resources/vue/Admin/User.vue b/src/resources/vue/Admin/User.vue
index aead8af1..1fd4a604 100644
--- a/src/resources/vue/Admin/User.vue
+++ b/src/resources/vue/Admin/User.vue
@@ -1,656 +1,656 @@
Do you really want to delete this user permanently?
This will delete all account data and withdraw the permission to access the email account.
Please note that this action cannot be undone.
diff --git a/src/tests/Browser/Admin/UserTest.php b/src/tests/Browser/Admin/UserTest.php
index 43e498df..ab632794 100644
--- a/src/tests/Browser/Admin/UserTest.php
+++ b/src/tests/Browser/Admin/UserTest.php
@@ -1,443 +1,470 @@
getTestUser('john@kolab.org');
$john->setSettings([
'phone' => '+48123123123',
'external_email' => 'john.doe.external@gmail.com',
]);
if ($john->isSuspended()) {
User::where('email', $john->email)->update(['status' => $john->status - User::STATUS_SUSPENDED]);
}
$wallet = $john->wallets()->first();
$wallet->discount()->dissociate();
$wallet->save();
+ Entitlement::where('cost', '>=', 5000)->delete();
+
$this->clearMeetEntitlements();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$john = $this->getTestUser('john@kolab.org');
$john->setSettings([
'phone' => null,
'external_email' => 'john.doe.external@gmail.com',
]);
if ($john->isSuspended()) {
User::where('email', $john->email)->update(['status' => $john->status - User::STATUS_SUSPENDED]);
}
$wallet = $john->wallets()->first();
$wallet->discount()->dissociate();
$wallet->save();
+ Entitlement::where('cost', '>=', 5000)->delete();
+
$this->clearMeetEntitlements();
parent::tearDown();
}
/**
* Test user info page (unauthenticated)
*/
public function testUserUnauth(): void
{
// Test that the page requires authentication
$this->browse(function (Browser $browser) {
$jack = $this->getTestUser('jack@kolab.org');
$browser->visit('/user/' . $jack->id)->on(new Home());
});
}
/**
* Test user info page
*/
public function testUserInfo(): void
{
$this->browse(function (Browser $browser) {
$jack = $this->getTestUser('jack@kolab.org');
$page = new UserPage($jack->id);
$browser->visit(new Home())
->submitLogon('jeroen@jeroen.jeroen', 'jeroen', true)
->on(new Dashboard())
->visit($page)
->on($page);
// Assert main info box content
$browser->assertSeeIn('@user-info .card-title', $jack->email)
->with('@user-info form', function (Browser $browser) use ($jack) {
$browser->assertElementsCount('.row', 7)
->assertSeeIn('.row:nth-child(1) label', 'Managed by')
->assertSeeIn('.row:nth-child(1) #manager a', 'john@kolab.org')
->assertSeeIn('.row:nth-child(2) label', 'ID (Created at)')
->assertSeeIn('.row:nth-child(2) #userid', "{$jack->id} ({$jack->created_at})")
->assertSeeIn('.row:nth-child(3) label', 'Status')
->assertSeeIn('.row:nth-child(3) #status span.text-success', 'Active')
->assertSeeIn('.row:nth-child(4) label', 'First name')
->assertSeeIn('.row:nth-child(4) #first_name', 'Jack')
->assertSeeIn('.row:nth-child(5) label', 'Last name')
->assertSeeIn('.row:nth-child(5) #last_name', 'Daniels')
->assertSeeIn('.row:nth-child(6) label', 'External email')
->assertMissing('.row:nth-child(6) #external_email a')
->assertSeeIn('.row:nth-child(7) label', 'Country')
->assertSeeIn('.row:nth-child(7) #country', 'United States');
});
// Some tabs are loaded in background, wait a second
$browser->pause(500)
->assertElementsCount('@nav a', 5);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
// Assert Aliases tab
$browser->assertSeeIn('@nav #tab-aliases', 'Aliases (1)')
->click('@nav #tab-aliases')
->whenAvailable('@user-aliases', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 1)
->assertSeeIn('table tbody tr:first-child td:first-child', 'jack.daniels@kolab.org')
->assertMissing('table tfoot');
});
// Assert Subscriptions tab
$browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (3)')
->click('@nav #tab-subscriptions')
->with('@user-subscriptions', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 3)
->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox')
->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '4,44 CHF')
->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 2 GB')
->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF')
->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features')
->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '5,55 CHF')
->assertMissing('table tfoot')
->assertMissing('#reset2fa');
});
// Assert Domains tab
$browser->assertSeeIn('@nav #tab-domains', 'Domains (0)')
->click('@nav #tab-domains')
->with('@user-domains', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no domains in this account.');
});
// Assert Users tab
$browser->assertSeeIn('@nav #tab-users', 'Users (0)')
->click('@nav #tab-users')
->with('@user-users', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no users in this account.');
});
});
}
/**
* Test user info page (continue)
*
* @depends testUserInfo
*/
public function testUserInfo2(): void
{
$this->browse(function (Browser $browser) {
$john = $this->getTestUser('john@kolab.org');
$page = new UserPage($john->id);
$discount = Discount::where('code', 'TEST')->first();
$wallet = $john->wallet();
$wallet->discount()->associate($discount);
$wallet->debit(2010);
$wallet->save();
// Click the managed-by link on Jack's page
$browser->click('@user-info #manager a')
->on($page);
// Assert main info box content
$browser->assertSeeIn('@user-info .card-title', $john->email)
->with('@user-info form', function (Browser $browser) use ($john) {
$ext_email = $john->getSetting('external_email');
$browser->assertElementsCount('.row', 9)
->assertSeeIn('.row:nth-child(1) label', 'ID (Created at)')
->assertSeeIn('.row:nth-child(1) #userid', "{$john->id} ({$john->created_at})")
->assertSeeIn('.row:nth-child(2) label', 'Status')
->assertSeeIn('.row:nth-child(2) #status span.text-success', 'Active')
->assertSeeIn('.row:nth-child(3) label', 'First name')
->assertSeeIn('.row:nth-child(3) #first_name', 'John')
->assertSeeIn('.row:nth-child(4) label', 'Last name')
->assertSeeIn('.row:nth-child(4) #last_name', 'Doe')
->assertSeeIn('.row:nth-child(5) label', 'Organization')
->assertSeeIn('.row:nth-child(5) #organization', 'Kolab Developers')
->assertSeeIn('.row:nth-child(6) label', 'Phone')
->assertSeeIn('.row:nth-child(6) #phone', $john->getSetting('phone'))
->assertSeeIn('.row:nth-child(7) label', 'External email')
->assertSeeIn('.row:nth-child(7) #external_email a', $ext_email)
->assertAttribute('.row:nth-child(7) #external_email a', 'href', "mailto:$ext_email")
->assertSeeIn('.row:nth-child(8) label', 'Address')
->assertSeeIn('.row:nth-child(8) #billing_address', $john->getSetting('billing_address'))
->assertSeeIn('.row:nth-child(9) label', 'Country')
->assertSeeIn('.row:nth-child(9) #country', 'United States');
});
// Some tabs are loaded in background, wait a second
$browser->pause(500)
->assertElementsCount('@nav a', 5);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
// Assert Aliases tab
$browser->assertSeeIn('@nav #tab-aliases', 'Aliases (1)')
->click('@nav #tab-aliases')
->whenAvailable('@user-aliases', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 1)
->assertSeeIn('table tbody tr:first-child td:first-child', 'john.doe@kolab.org')
->assertMissing('table tfoot');
});
// Assert Subscriptions tab
$browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (3)')
->click('@nav #tab-subscriptions')
->with('@user-subscriptions', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 3)
->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox')
->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '3,99 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 2 GB')
->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features')
->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,99 CHF/month¹')
->assertMissing('table tfoot')
->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher');
});
// Assert Domains tab
$browser->assertSeeIn('@nav #tab-domains', 'Domains (1)')
->click('@nav #tab-domains')
->with('@user-domains table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 1)
->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'kolab.org')
->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-success')
->assertMissing('tfoot');
});
// Assert Users tab
$browser->assertSeeIn('@nav #tab-users', 'Users (4)')
->click('@nav #tab-users')
->with('@user-users table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 4)
->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'jack@kolab.org')
->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-success')
->assertSeeIn('tbody tr:nth-child(2) td:first-child a', 'joe@kolab.org')
->assertVisible('tbody tr:nth-child(2) td:first-child svg.text-success')
->assertSeeIn('tbody tr:nth-child(3) td:first-child span', 'john@kolab.org')
->assertVisible('tbody tr:nth-child(3) td:first-child svg.text-success')
->assertSeeIn('tbody tr:nth-child(4) td:first-child a', 'ned@kolab.org')
->assertVisible('tbody tr:nth-child(4) td:first-child svg.text-success')
->assertMissing('tfoot');
});
});
// Now we go to Ned's info page, he's a controller on John's wallet
$this->browse(function (Browser $browser) {
$ned = $this->getTestUser('ned@kolab.org');
+ $beta_sku = Sku::where('title', 'beta')->first();
+ $storage_sku = Sku::where('title', 'storage')->first();
+ $wallet = $ned->wallet();
+
+ // Add an extra storage and beta entitlement with different prices
+ Entitlement::create([
+ 'wallet_id' => $wallet->id,
+ 'sku_id' => $beta_sku->id,
+ 'cost' => 5010,
+ 'entitleable_id' => $ned->id,
+ 'entitleable_type' => User::class
+ ]);
+ Entitlement::create([
+ 'wallet_id' => $wallet->id,
+ 'sku_id' => $storage_sku->id,
+ 'cost' => 5000,
+ 'entitleable_id' => $ned->id,
+ 'entitleable_type' => User::class
+ ]);
+
$page = new UserPage($ned->id);
$browser->click('@user-users tbody tr:nth-child(4) td:first-child a')
->on($page);
// Assert main info box content
$browser->assertSeeIn('@user-info .card-title', $ned->email)
->with('@user-info form', function (Browser $browser) use ($ned) {
$browser->assertSeeIn('.row:nth-child(2) label', 'ID (Created at)')
->assertSeeIn('.row:nth-child(2) #userid', "{$ned->id} ({$ned->created_at})");
});
// Some tabs are loaded in background, wait a second
$browser->pause(500)
->assertElementsCount('@nav a', 5);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
// Assert Aliases tab
$browser->assertSeeIn('@nav #tab-aliases', 'Aliases (0)')
->click('@nav #tab-aliases')
->whenAvailable('@user-aliases', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'This user has no email aliases.');
});
// Assert Subscriptions tab, we expect John's discount here
- $browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (5)')
+ $browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (6)')
->click('@nav #tab-subscriptions')
->with('@user-subscriptions', function (Browser $browser) {
- $browser->assertElementsCount('table tbody tr', 5)
+ $browser->assertElementsCount('table tbody tr', 6)
->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox')
->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '3,99 CHF/month¹')
- ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 2 GB')
- ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month¹')
+ ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 3 GB')
+ ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '45,00 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features')
->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,99 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(4) td:first-child', 'Activesync')
->assertSeeIn('table tbody tr:nth-child(4) td:last-child', '0,90 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(5) td:first-child', '2-Factor Authentication')
->assertSeeIn('table tbody tr:nth-child(5) td:last-child', '0,00 CHF/month¹')
+ ->assertSeeIn('table tbody tr:nth-child(6) td:first-child', 'Private Beta (invitation only)')
+ ->assertSeeIn('table tbody tr:nth-child(6) td:last-child', '45,09 CHF/month¹')
->assertMissing('table tfoot')
->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher')
->assertSeeIn('#reset2fa', 'Reset 2-Factor Auth');
});
// We don't expect John's domains here
$browser->assertSeeIn('@nav #tab-domains', 'Domains (0)')
->click('@nav #tab-domains')
->with('@user-domains', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no domains in this account.');
});
// We don't expect John's users here
$browser->assertSeeIn('@nav #tab-users', 'Users (0)')
->click('@nav #tab-users')
->with('@user-users', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no users in this account.');
});
});
}
/**
* Test editing an external email
*
* @depends testUserInfo2
*/
public function testExternalEmail(): void
{
$this->browse(function (Browser $browser) {
$john = $this->getTestUser('john@kolab.org');
$browser->visit(new UserPage($john->id))
->waitFor('@user-info #external_email button')
->click('@user-info #external_email button')
// Test dialog content, and closing it with Cancel button
->with(new Dialog('#email-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'External email')
->assertFocused('@body input')
->assertValue('@body input', 'john.doe.external@gmail.com')
->assertSeeIn('@button-cancel', 'Cancel')
->assertSeeIn('@button-action', 'Submit')
->click('@button-cancel');
})
->assertMissing('#email-dialog')
->click('@user-info #external_email button')
// Test email validation error handling, and email update
->with(new Dialog('#email-dialog'), function (Browser $browser) {
$browser->type('@body input', 'test')
->click('@button-action')
->waitFor('@body input.is-invalid')
->assertSeeIn(
'@body input + .invalid-feedback',
'The external email must be a valid email address.'
)
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->type('@body input', 'test@test.com')
->click('@button-action');
})
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.')
->assertSeeIn('@user-info #external_email a', 'test@test.com')
->click('@user-info #external_email button')
->with(new Dialog('#email-dialog'), function (Browser $browser) {
$browser->assertValue('@body input', 'test@test.com')
->assertMissing('@body input.is-invalid')
->assertMissing('@body input + .invalid-feedback')
->click('@button-cancel');
})
->assertSeeIn('@user-info #external_email a', 'test@test.com');
// $john->getSetting() may not work here as it uses internal cache
// read the value form database
$current_ext_email = $john->settings()->where('key', 'external_email')->first()->value;
$this->assertSame('test@test.com', $current_ext_email);
});
}
/**
* Test suspending/unsuspending the user
*/
public function testSuspendAndUnsuspend(): void
{
$this->browse(function (Browser $browser) {
$john = $this->getTestUser('john@kolab.org');
$browser->visit(new UserPage($john->id))
->assertVisible('@user-info #button-suspend')
->assertMissing('@user-info #button-unsuspend')
->click('@user-info #button-suspend')
->assertToast(Toast::TYPE_SUCCESS, 'User suspended successfully.')
->assertSeeIn('@user-info #status span.text-warning', 'Suspended')
->assertMissing('@user-info #button-suspend')
->click('@user-info #button-unsuspend')
->assertToast(Toast::TYPE_SUCCESS, 'User unsuspended successfully.')
->assertSeeIn('@user-info #status span.text-success', 'Active')
->assertVisible('@user-info #button-suspend')
->assertMissing('@user-info #button-unsuspend');
});
}
/**
* Test resetting 2FA for the user
*/
public function testReset2FA(): void
{
$this->browse(function (Browser $browser) {
$this->deleteTestUser('userstest1@kolabnow.com');
$user = $this->getTestUser('userstest1@kolabnow.com');
$sku2fa = Sku::firstOrCreate(['title' => '2fa']);
$user->assignSku($sku2fa);
SecondFactor::seed('userstest1@kolabnow.com');
$browser->visit(new UserPage($user->id))
->click('@nav #tab-subscriptions')
->with('@user-subscriptions', function (Browser $browser) use ($sku2fa) {
$browser->waitFor('#reset2fa')
->assertVisible('#sku' . $sku2fa->id);
})
->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (1)')
->click('#reset2fa')
->with(new Dialog('#reset-2fa-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', '2-Factor Authentication Reset')
->assertSeeIn('@button-cancel', 'Cancel')
->assertSeeIn('@button-action', 'Reset')
->click('@button-action');
})
->assertToast(Toast::TYPE_SUCCESS, '2-Factor authentication reset successfully.')
->assertMissing('#sku' . $sku2fa->id)
->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (0)');
});
}
}
diff --git a/src/tests/Browser/UsersTest.php b/src/tests/Browser/UsersTest.php
index f6a2250a..a2d2cc32 100644
--- a/src/tests/Browser/UsersTest.php
+++ b/src/tests/Browser/UsersTest.php
@@ -1,658 +1,705 @@
'John',
'last_name' => 'Doe',
'organization' => 'Kolab Developers',
];
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestUser('julia.roberts@kolab.org');
$john = User::where('email', 'john@kolab.org')->first();
$john->setSettings($this->profile);
UserAlias::where('user_id', $john->id)
->where('alias', 'john.test@kolab.org')->delete();
Entitlement::where('entitleable_id', $john->id)->whereIn('cost', [25, 100])->delete();
+ Entitlement::where('cost', '>=', 5000)->delete();
$wallet = $john->wallets()->first();
$wallet->discount()->dissociate();
$wallet->save();
$this->clearBetaEntitlements();
$this->clearMeetEntitlements();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestUser('julia.roberts@kolab.org');
$john = User::where('email', 'john@kolab.org')->first();
$john->setSettings($this->profile);
UserAlias::where('user_id', $john->id)
->where('alias', 'john.test@kolab.org')->delete();
Entitlement::where('entitleable_id', $john->id)->whereIn('cost', [25, 100])->delete();
+ Entitlement::where('cost', '>=', 5000)->delete();
$wallet = $john->wallets()->first();
$wallet->discount()->dissociate();
$wallet->save();
$this->clearBetaEntitlements();
$this->clearMeetEntitlements();
parent::tearDown();
}
/**
* Test user info page (unauthenticated)
*/
public function testInfoUnauth(): void
{
// Test that the page requires authentication
$this->browse(function (Browser $browser) {
$user = User::where('email', 'john@kolab.org')->first();
$browser->visit('/user/' . $user->id)->on(new Home());
});
}
/**
* Test users list page (unauthenticated)
*/
public function testListUnauth(): void
{
// Test that the page requires authentication
$this->browse(function (Browser $browser) {
$browser->visit('/users')->on(new Home());
});
}
/**
* Test users list page
*/
public function testList(): void
{
// Test that the page requires authentication
$this->browse(function (Browser $browser) {
$browser->visit(new Home())
->submitLogon('john@kolab.org', 'simple123', true)
->on(new Dashboard())
->assertSeeIn('@links .link-users', 'User accounts')
->click('@links .link-users')
->on(new UserList())
->whenAvailable('@table', function (Browser $browser) {
$browser->waitFor('tbody tr')
->assertElementsCount('tbody tr', 4)
->assertSeeIn('tbody tr:nth-child(1) a', 'jack@kolab.org')
->assertSeeIn('tbody tr:nth-child(2) a', 'joe@kolab.org')
->assertSeeIn('tbody tr:nth-child(3) a', 'john@kolab.org')
->assertSeeIn('tbody tr:nth-child(4) a', 'ned@kolab.org')
->assertMissing('tfoot');
});
});
}
/**
* Test user account editing page (not profile page)
*
* @depends testList
*/
public function testInfo(): void
{
$this->browse(function (Browser $browser) {
$browser->on(new UserList())
->click('@table tr:nth-child(3) a')
->on(new UserInfo())
->assertSeeIn('#user-info .card-title', 'User account')
->with('@form', function (Browser $browser) {
// Assert form content
$browser->assertSeeIn('div.row:nth-child(1) label', 'Status')
->assertSeeIn('div.row:nth-child(1) #status', 'Active')
->assertFocused('div.row:nth-child(2) input')
->assertSeeIn('div.row:nth-child(2) label', 'First name')
->assertValue('div.row:nth-child(2) input[type=text]', $this->profile['first_name'])
->assertSeeIn('div.row:nth-child(3) label', 'Last name')
->assertValue('div.row:nth-child(3) input[type=text]', $this->profile['last_name'])
->assertSeeIn('div.row:nth-child(4) label', 'Organization')
->assertValue('div.row:nth-child(4) input[type=text]', $this->profile['organization'])
->assertSeeIn('div.row:nth-child(5) label', 'Email')
->assertValue('div.row:nth-child(5) input[type=text]', 'john@kolab.org')
->assertDisabled('div.row:nth-child(5) input[type=text]')
->assertSeeIn('div.row:nth-child(6) label', 'Email aliases')
->assertVisible('div.row:nth-child(6) .list-input')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->assertListInputValue(['john.doe@kolab.org'])
->assertValue('@input', '');
})
->assertSeeIn('div.row:nth-child(7) label', 'Password')
->assertValue('div.row:nth-child(7) input[type=password]', '')
->assertSeeIn('div.row:nth-child(8) label', 'Confirm password')
->assertValue('div.row:nth-child(8) input[type=password]', '')
->assertSeeIn('button[type=submit]', 'Submit')
// Clear some fields and submit
->vueClear('#first_name')
->vueClear('#last_name')
->click('button[type=submit]');
})
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.')
->on(new UserList())
->click('@table tr:nth-child(3) a')
->on(new UserInfo())
->assertSeeIn('#user-info .card-title', 'User account')
->with('@form', function (Browser $browser) {
// Test error handling (password)
$browser->type('#password', 'aaaaaa')
->vueClear('#password_confirmation')
->click('button[type=submit]')
->waitFor('#password + .invalid-feedback')
->assertSeeIn('#password + .invalid-feedback', 'The password confirmation does not match.')
->assertFocused('#password')
->assertToast(Toast::TYPE_ERROR, 'Form validation error');
// TODO: Test password change
// Test form error handling (aliases)
$browser->vueClear('#password')
->vueClear('#password_confirmation')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->addListEntry('invalid address');
})
->click('button[type=submit]')
->assertToast(Toast::TYPE_ERROR, 'Form validation error');
$browser->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->assertFormError(2, 'The specified alias is invalid.', false);
});
// Test adding aliases
$browser->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->removeListEntry(2)
->addListEntry('john.test@kolab.org');
})
->click('button[type=submit]')
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.');
})
->on(new UserList())
->click('@table tr:nth-child(3) a')
->on(new UserInfo());
$john = User::where('email', 'john@kolab.org')->first();
$alias = UserAlias::where('user_id', $john->id)->where('alias', 'john.test@kolab.org')->first();
$this->assertTrue(!empty($alias));
// Test subscriptions
$browser->with('@form', function (Browser $browser) {
$browser->assertSeeIn('div.row:nth-child(9) label', 'Subscriptions')
->assertVisible('@skus.row:nth-child(9)')
->with('@skus', function ($browser) {
$browser->assertElementsCount('tbody tr', 6)
// Mailbox SKU
->assertSeeIn('tbody tr:nth-child(1) td.name', 'User Mailbox')
->assertSeeIn('tbody tr:nth-child(1) td.price', '4,44 CHF/month')
->assertChecked('tbody tr:nth-child(1) td.selection input')
->assertDisabled('tbody tr:nth-child(1) td.selection input')
->assertTip(
'tbody tr:nth-child(1) td.buttons button',
'Just a mailbox'
)
// Storage SKU
->assertSeeIn('tbody tr:nth-child(2) td.name', 'Storage Quota')
->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/month')
->assertChecked('tbody tr:nth-child(2) td.selection input')
->assertDisabled('tbody tr:nth-child(2) td.selection input')
->assertTip(
'tbody tr:nth-child(2) td.buttons button',
'Some wiggle room'
)
->with(new QuotaInput('tbody tr:nth-child(2) .range-input'), function ($browser) {
$browser->assertQuotaValue(2)->setQuotaValue(3);
})
->assertSeeIn('tr:nth-child(2) td.price', '0,25 CHF/month')
// groupware SKU
->assertSeeIn('tbody tr:nth-child(3) td.name', 'Groupware Features')
->assertSeeIn('tbody tr:nth-child(3) td.price', '5,55 CHF/month')
->assertChecked('tbody tr:nth-child(3) td.selection input')
->assertEnabled('tbody tr:nth-child(3) td.selection input')
->assertTip(
'tbody tr:nth-child(3) td.buttons button',
'Groupware functions like Calendar, Tasks, Notes, etc.'
)
// ActiveSync SKU
->assertSeeIn('tbody tr:nth-child(4) td.name', 'Activesync')
->assertSeeIn('tbody tr:nth-child(4) td.price', '1,00 CHF/month')
->assertNotChecked('tbody tr:nth-child(4) td.selection input')
->assertEnabled('tbody tr:nth-child(4) td.selection input')
->assertTip(
'tbody tr:nth-child(4) td.buttons button',
'Mobile synchronization'
)
// 2FA SKU
->assertSeeIn('tbody tr:nth-child(5) td.name', '2-Factor Authentication')
->assertSeeIn('tbody tr:nth-child(5) td.price', '0,00 CHF/month')
->assertNotChecked('tbody tr:nth-child(5) td.selection input')
->assertEnabled('tbody tr:nth-child(5) td.selection input')
->assertTip(
'tbody tr:nth-child(5) td.buttons button',
'Two factor authentication for webmail and administration panel'
)
// Meet SKU
->assertSeeIn('tbody tr:nth-child(6) td.name', 'Voice & Video Conferencing (public beta)')
->assertSeeIn('tbody tr:nth-child(6) td.price', '0,00 CHF/month')
->assertNotChecked('tbody tr:nth-child(6) td.selection input')
->assertEnabled('tbody tr:nth-child(6) td.selection input')
->assertTip(
'tbody tr:nth-child(6) td.buttons button',
'Video conferencing tool'
)
->click('tbody tr:nth-child(4) td.selection input');
})
->assertMissing('@skus table + .hint')
->click('button[type=submit]')
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.');
})
->on(new UserList())
->click('@table tr:nth-child(3) a')
->on(new UserInfo());
$expected = ['activesync', 'groupware', 'mailbox', 'storage', 'storage', 'storage'];
$this->assertUserEntitlements($john, $expected);
// Test subscriptions interaction
$browser->with('@form', function (Browser $browser) {
$browser->with('@skus', function ($browser) {
// Uncheck 'groupware', expect activesync unchecked
$browser->click('#sku-input-groupware')
->assertNotChecked('#sku-input-groupware')
->assertNotChecked('#sku-input-activesync')
->assertEnabled('#sku-input-activesync')
->assertNotReadonly('#sku-input-activesync')
// Check 'activesync', expect an alert
->click('#sku-input-activesync')
->assertDialogOpened('Activesync requires Groupware Features.')
->acceptDialog()
->assertNotChecked('#sku-input-activesync')
// Check 'meet', expect an alert
->click('#sku-input-meet')
->assertDialogOpened('Voice & Video Conferencing (public beta) requires Groupware Features.')
->acceptDialog()
->assertNotChecked('#sku-input-meet')
// Check '2FA', expect 'activesync' unchecked and readonly
->click('#sku-input-2fa')
->assertChecked('#sku-input-2fa')
->assertNotChecked('#sku-input-activesync')
->assertReadonly('#sku-input-activesync')
// Uncheck '2FA'
->click('#sku-input-2fa')
->assertNotChecked('#sku-input-2fa')
->assertNotReadonly('#sku-input-activesync');
});
});
});
}
/**
* Test user adding page
*
* @depends testList
*/
public function testNewUser(): void
{
$this->browse(function (Browser $browser) {
$browser->visit(new UserList())
->assertSeeIn('button.create-user', 'Create user')
->click('button.create-user')
->on(new UserInfo())
->assertSeeIn('#user-info .card-title', 'New user account')
->with('@form', function (Browser $browser) {
// Assert form content
$browser->assertFocused('div.row:nth-child(1) input')
->assertSeeIn('div.row:nth-child(1) label', 'First name')
->assertValue('div.row:nth-child(1) input[type=text]', '')
->assertSeeIn('div.row:nth-child(2) label', 'Last name')
->assertValue('div.row:nth-child(2) input[type=text]', '')
->assertSeeIn('div.row:nth-child(3) label', 'Organization')
->assertValue('div.row:nth-child(3) input[type=text]', '')
->assertSeeIn('div.row:nth-child(4) label', 'Email')
->assertValue('div.row:nth-child(4) input[type=text]', '')
->assertEnabled('div.row:nth-child(4) input[type=text]')
->assertSeeIn('div.row:nth-child(5) label', 'Email aliases')
->assertVisible('div.row:nth-child(5) .list-input')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->assertListInputValue([])
->assertValue('@input', '');
})
->assertSeeIn('div.row:nth-child(6) label', 'Password')
->assertValue('div.row:nth-child(6) input[type=password]', '')
->assertSeeIn('div.row:nth-child(7) label', 'Confirm password')
->assertValue('div.row:nth-child(7) input[type=password]', '')
->assertSeeIn('div.row:nth-child(8) label', 'Package')
// assert packages list widget, select "Lite Account"
->with('@packages', function ($browser) {
$browser->assertElementsCount('tbody tr', 2)
->assertSeeIn('tbody tr:nth-child(1)', 'Groupware Account')
->assertSeeIn('tbody tr:nth-child(2)', 'Lite Account')
->assertSeeIn('tbody tr:nth-child(1) .price', '9,99 CHF/month')
->assertSeeIn('tbody tr:nth-child(2) .price', '4,44 CHF/month')
->assertChecked('tbody tr:nth-child(1) input')
->click('tbody tr:nth-child(2) input')
->assertNotChecked('tbody tr:nth-child(1) input')
->assertChecked('tbody tr:nth-child(2) input');
})
->assertMissing('@packages table + .hint')
->assertSeeIn('button[type=submit]', 'Submit');
// Test browser-side required fields and error handling
$browser->click('button[type=submit]')
->assertFocused('#email')
->type('#email', 'invalid email')
->click('button[type=submit]')
->assertFocused('#password')
->type('#password', 'simple123')
->click('button[type=submit]')
->assertFocused('#password_confirmation')
->type('#password_confirmation', 'simple')
->click('button[type=submit]')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->assertSeeIn('#email + .invalid-feedback', 'The specified email is invalid.')
->assertSeeIn('#password + .invalid-feedback', 'The password confirmation does not match.');
});
// Test form error handling (aliases)
$browser->with('@form', function (Browser $browser) {
$browser->type('#email', 'julia.roberts@kolab.org')
->type('#password_confirmation', 'simple123')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->addListEntry('invalid address');
})
->click('button[type=submit]')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->assertFormError(1, 'The specified alias is invalid.', false);
});
});
// Successful account creation
$browser->with('@form', function (Browser $browser) {
$browser->type('#first_name', 'Julia')
->type('#last_name', 'Roberts')
->type('#organization', 'Test Org')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->removeListEntry(1)
->addListEntry('julia.roberts2@kolab.org');
})
->click('button[type=submit]');
})
->assertToast(Toast::TYPE_SUCCESS, 'User created successfully.')
// check redirection to users list
->on(new UserList())
->whenAvailable('@table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 5)
->assertSeeIn('tbody tr:nth-child(4) a', 'julia.roberts@kolab.org');
});
$julia = User::where('email', 'julia.roberts@kolab.org')->first();
$alias = UserAlias::where('user_id', $julia->id)->where('alias', 'julia.roberts2@kolab.org')->first();
$this->assertTrue(!empty($alias));
$this->assertUserEntitlements($julia, ['mailbox', 'storage', 'storage']);
$this->assertSame('Julia', $julia->getSetting('first_name'));
$this->assertSame('Roberts', $julia->getSetting('last_name'));
$this->assertSame('Test Org', $julia->getSetting('organization'));
// Some additional tests for the list input widget
$browser->click('tbody tr:nth-child(4) a')
->on(new UserInfo())
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->assertListInputValue(['julia.roberts2@kolab.org'])
->addListEntry('invalid address')
->type('.input-group:nth-child(2) input', '@kolab.org');
})
->click('button[type=submit]')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->assertVisible('.input-group:nth-child(2) input.is-invalid')
->assertVisible('.input-group:nth-child(3) input.is-invalid')
->type('.input-group:nth-child(2) input', 'julia.roberts3@kolab.org')
->type('.input-group:nth-child(3) input', 'julia.roberts4@kolab.org');
})
->click('button[type=submit]')
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.');
$julia = User::where('email', 'julia.roberts@kolab.org')->first();
$aliases = $julia->aliases()->orderBy('alias')->get()->pluck('alias')->all();
$this->assertSame(['julia.roberts3@kolab.org', 'julia.roberts4@kolab.org'], $aliases);
});
}
/**
* Test user delete
*
* @depends testNewUser
*/
public function testDeleteUser(): void
{
// First create a new user
$john = $this->getTestUser('john@kolab.org');
$julia = $this->getTestUser('julia.roberts@kolab.org');
$package_kolab = \App\Package::where('title', 'kolab')->first();
$john->assignPackage($package_kolab, $julia);
// Test deleting non-controller user
$this->browse(function (Browser $browser) use ($julia) {
$browser->visit('/user/' . $julia->id)
->on(new UserInfo())
->assertSeeIn('button.button-delete', 'Delete user')
->click('button.button-delete')
->with(new Dialog('#delete-warning'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'Delete julia.roberts@kolab.org')
->assertFocused('@button-cancel')
->assertSeeIn('@button-cancel', 'Cancel')
->assertSeeIn('@button-action', 'Delete')
->click('@button-cancel');
})
->waitUntilMissing('#delete-warning')
->click('button.button-delete')
->with(new Dialog('#delete-warning'), function (Browser $browser) {
$browser->click('@button-action');
})
->waitUntilMissing('#delete-warning')
->assertToast(Toast::TYPE_SUCCESS, 'User deleted successfully.')
->on(new UserList())
->with('@table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 4)
->assertSeeIn('tbody tr:nth-child(1) a', 'jack@kolab.org')
->assertSeeIn('tbody tr:nth-child(2) a', 'joe@kolab.org')
->assertSeeIn('tbody tr:nth-child(3) a', 'john@kolab.org')
->assertSeeIn('tbody tr:nth-child(4) a', 'ned@kolab.org');
});
$julia = User::where('email', 'julia.roberts@kolab.org')->first();
$this->assertTrue(empty($julia));
});
// Test that non-controller user cannot see/delete himself on the users list
$this->browse(function (Browser $browser) {
$browser->visit('/logout')
->on(new Home())
->submitLogon('jack@kolab.org', 'simple123', true)
->visit(new UserList())
->whenAvailable('@table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 0)
->assertSeeIn('tfoot td', 'There are no users in this account.');
});
});
// Test that controller user (Ned) can see all the users
$this->browse(function (Browser $browser) {
$browser->visit('/logout')
->on(new Home())
->submitLogon('ned@kolab.org', 'simple123', true)
->visit(new UserList())
->whenAvailable('@table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 4);
});
// TODO: Test the delete action in details
});
// TODO: Test what happens with the logged in user session after he's been deleted by another user
}
/**
* Test discounted sku/package prices in the UI
*/
public function testDiscountedPrices(): void
{
// Add 10% discount
$discount = Discount::where('code', 'TEST')->first();
$john = User::where('email', 'john@kolab.org')->first();
$wallet = $john->wallet();
$wallet->discount()->associate($discount);
$wallet->save();
// SKUs on user edit page
$this->browse(function (Browser $browser) {
$browser->visit('/logout')
->on(new Home())
->submitLogon('john@kolab.org', 'simple123', true)
->visit(new UserList())
->waitFor('@table tr:nth-child(2)')
- ->click('@table tr:nth-child(2) a')
+ ->click('@table tr:nth-child(2) a') // joe@kolab.org
->on(new UserInfo())
->with('@form', function (Browser $browser) {
$browser->whenAvailable('@skus', function (Browser $browser) {
$quota_input = new QuotaInput('tbody tr:nth-child(2) .range-input');
$browser->waitFor('tbody tr')
->assertElementsCount('tbody tr', 6)
// Mailbox SKU
->assertSeeIn('tbody tr:nth-child(1) td.price', '3,99 CHF/month¹')
// Storage SKU
->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/month¹')
->with($quota_input, function (Browser $browser) {
$browser->setQuotaValue(100);
})
- ->assertSeeIn('tr:nth-child(2) td.price', '21,56 CHF/month¹')
+ ->assertSeeIn('tr:nth-child(2) td.price', '22,05 CHF/month¹')
// groupware SKU
->assertSeeIn('tbody tr:nth-child(3) td.price', '4,99 CHF/month¹')
// ActiveSync SKU
->assertSeeIn('tbody tr:nth-child(4) td.price', '0,90 CHF/month¹')
// 2FA SKU
->assertSeeIn('tbody tr:nth-child(5) td.price', '0,00 CHF/month¹');
})
->assertSeeIn('@skus table + .hint', '¹ applied discount: 10% - Test voucher');
});
});
// Packages on new user page
$this->browse(function (Browser $browser) {
$browser->visit(new UserList())
->click('button.create-user')
->on(new UserInfo())
->with('@form', function (Browser $browser) {
$browser->whenAvailable('@packages', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 2)
->assertSeeIn('tbody tr:nth-child(1) .price', '8,99 CHF/month¹') // Groupware
->assertSeeIn('tbody tr:nth-child(2) .price', '3,99 CHF/month¹'); // Lite
})
->assertSeeIn('@packages table + .hint', '¹ applied discount: 10% - Test voucher');
});
});
+
+ // Test using entitlement cost instead of the SKU cost
+ $this->browse(function (Browser $browser) use ($wallet) {
+ $joe = User::where('email', 'joe@kolab.org')->first();
+ $beta_sku = Sku::where('title', 'beta')->first();
+ $storage_sku = Sku::where('title', 'storage')->first();
+
+ // Add an extra storage and beta entitlement with different prices
+ Entitlement::create([
+ 'wallet_id' => $wallet->id,
+ 'sku_id' => $beta_sku->id,
+ 'cost' => 5010,
+ 'entitleable_id' => $joe->id,
+ 'entitleable_type' => User::class
+ ]);
+ Entitlement::create([
+ 'wallet_id' => $wallet->id,
+ 'sku_id' => $storage_sku->id,
+ 'cost' => 5000,
+ 'entitleable_id' => $joe->id,
+ 'entitleable_type' => User::class
+ ]);
+
+ $browser->visit('/user/' . $joe->id)
+ ->on(new UserInfo())
+ ->with('@form', function (Browser $browser) {
+ $browser->whenAvailable('@skus', function (Browser $browser) {
+ $quota_input = new QuotaInput('tbody tr:nth-child(2) .range-input');
+ $browser->waitFor('tbody tr')
+ // Beta SKU
+ ->assertSeeIn('tbody tr:nth-child(7) td.price', '45,09 CHF/month¹')
+ // Storage SKU
+ ->assertSeeIn('tr:nth-child(2) td.price', '45,00 CHF/month¹')
+ ->with($quota_input, function (Browser $browser) {
+ $browser->setQuotaValue(4);
+ })
+ ->assertSeeIn('tr:nth-child(2) td.price', '45,22 CHF/month¹')
+ ->with($quota_input, function (Browser $browser) {
+ $browser->setQuotaValue(2);
+ })
+ ->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/month¹');
+ })
+ ->assertSeeIn('@skus table + .hint', '¹ applied discount: 10% - Test voucher');
+ });
+ });
}
/**
* Test beta entitlements
*
* @depends testList
*/
public function testBetaEntitlements(): void
{
$this->browse(function (Browser $browser) {
$john = User::where('email', 'john@kolab.org')->first();
$sku = Sku::where('title', 'beta')->first();
$john->assignSku($sku);
$browser->visit('/user/' . $john->id)
->on(new UserInfo())
->with('@skus', function ($browser) {
$browser->assertElementsCount('tbody tr', 7)
// Beta/Meet SKU
->assertSeeIn('tbody tr:nth-child(6) td.name', 'Voice & Video Conferencing (public beta)')
->assertSeeIn('tr:nth-child(6) td.price', '0,00 CHF/month')
->assertNotChecked('tbody tr:nth-child(6) td.selection input')
->assertEnabled('tbody tr:nth-child(6) td.selection input')
->assertTip(
'tbody tr:nth-child(6) td.buttons button',
'Video conferencing tool'
)
// Beta SKU
->assertSeeIn('tbody tr:nth-child(7) td.name', 'Private Beta (invitation only)')
->assertSeeIn('tbody tr:nth-child(7) td.price', '0,00 CHF/month')
->assertChecked('tbody tr:nth-child(7) td.selection input')
->assertEnabled('tbody tr:nth-child(7) td.selection input')
->assertTip(
'tbody tr:nth-child(7) td.buttons button',
'Access to the private beta program subscriptions'
)
/*
// Check Meet, Uncheck Beta, expect Meet unchecked
->click('#sku-input-meet')
->click('#sku-input-beta')
->assertNotChecked('#sku-input-beta')
->assertNotChecked('#sku-input-meet')
// Click Meet expect an alert
->click('#sku-input-meet')
->assertDialogOpened('Video chat requires Beta program.')
->acceptDialog()
*/
// Enable Meet and submit
->click('#sku-input-meet');
})
->click('button[type=submit]')
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.');
$expected = ['beta', 'groupware', 'mailbox', 'meet', 'storage', 'storage'];
$this->assertUserEntitlements($john, $expected);
$browser->visit('/user/' . $john->id)
->on(new UserInfo())
->click('#sku-input-beta')
->click('#sku-input-meet')
->click('button[type=submit]')
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.');
$expected = ['groupware', 'mailbox', 'storage', 'storage'];
$this->assertUserEntitlements($john, $expected);
});
}
}
diff --git a/src/tests/Feature/Controller/UsersTest.php b/src/tests/Feature/Controller/UsersTest.php
index 485eb472..12b872af 100644
--- a/src/tests/Feature/Controller/UsersTest.php
+++ b/src/tests/Feature/Controller/UsersTest.php
@@ -1,1266 +1,1270 @@
deleteTestUser('jane@kolabnow.com');
$this->deleteTestUser('UsersControllerTest1@userscontroller.com');
$this->deleteTestUser('UsersControllerTest2@userscontroller.com');
$this->deleteTestUser('UsersControllerTest3@userscontroller.com');
$this->deleteTestUser('UserEntitlement2A@UserEntitlement.com');
$this->deleteTestUser('john2.doe2@kolab.org');
$this->deleteTestUser('deleted@kolab.org');
$this->deleteTestUser('deleted@kolabnow.com');
$this->deleteTestDomain('userscontroller.com');
$this->deleteTestGroup('group-test@kolabnow.com');
$this->deleteTestGroup('group-test@kolab.org');
$user = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
$wallet->discount()->dissociate();
$wallet->settings()->whereIn('key', ['mollie_id', 'stripe_id'])->delete();
$wallet->save();
$user->status |= User::STATUS_IMAP_READY;
$user->save();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestUser('jane@kolabnow.com');
$this->deleteTestUser('UsersControllerTest1@userscontroller.com');
$this->deleteTestUser('UsersControllerTest2@userscontroller.com');
$this->deleteTestUser('UsersControllerTest3@userscontroller.com');
$this->deleteTestUser('UserEntitlement2A@UserEntitlement.com');
$this->deleteTestUser('john2.doe2@kolab.org');
$this->deleteTestUser('deleted@kolab.org');
$this->deleteTestUser('deleted@kolabnow.com');
$this->deleteTestDomain('userscontroller.com');
$this->deleteTestGroup('group-test@kolabnow.com');
$this->deleteTestGroup('group-test@kolab.org');
$user = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
$wallet->discount()->dissociate();
$wallet->settings()->whereIn('key', ['mollie_id', 'stripe_id'])->delete();
$wallet->save();
$user->status |= User::STATUS_IMAP_READY;
$user->save();
parent::tearDown();
}
/**
* Test user deleting (DELETE /api/v4/users/)
*/
public function testDestroy(): void
{
// First create some users/accounts to delete
$package_kolab = \App\Package::where('title', 'kolab')->first();
$package_domain = \App\Package::where('title', 'domain-hosting')->first();
$john = $this->getTestUser('john@kolab.org');
$user1 = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$user2 = $this->getTestUser('UsersControllerTest2@userscontroller.com');
$user3 = $this->getTestUser('UsersControllerTest3@userscontroller.com');
$domain = $this->getTestDomain('userscontroller.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_PUBLIC,
]);
$user1->assignPackage($package_kolab);
$domain->assignPackage($package_domain, $user1);
$user1->assignPackage($package_kolab, $user2);
$user1->assignPackage($package_kolab, $user3);
// Test unauth access
$response = $this->delete("api/v4/users/{$user2->id}");
$response->assertStatus(401);
// Test access to other user/account
$response = $this->actingAs($john)->delete("api/v4/users/{$user2->id}");
$response->assertStatus(403);
$response = $this->actingAs($john)->delete("api/v4/users/{$user1->id}");
$response->assertStatus(403);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertSame("Access denied", $json['message']);
$this->assertCount(2, $json);
// Test that non-controller cannot remove himself
$response = $this->actingAs($user3)->delete("api/v4/users/{$user3->id}");
$response->assertStatus(403);
// Test removing a non-controller user
$response = $this->actingAs($user1)->delete("api/v4/users/{$user3->id}");
$response->assertStatus(200);
$json = $response->json();
$this->assertEquals('success', $json['status']);
$this->assertEquals('User deleted successfully.', $json['message']);
// Test removing self (an account with users)
$response = $this->actingAs($user1)->delete("api/v4/users/{$user1->id}");
$response->assertStatus(200);
$json = $response->json();
$this->assertEquals('success', $json['status']);
$this->assertEquals('User deleted successfully.', $json['message']);
}
/**
* Test user deleting (DELETE /api/v4/users/)
*/
public function testDestroyByController(): void
{
// Create an account with additional controller - $user2
$package_kolab = \App\Package::where('title', 'kolab')->first();
$package_domain = \App\Package::where('title', 'domain-hosting')->first();
$user1 = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$user2 = $this->getTestUser('UsersControllerTest2@userscontroller.com');
$user3 = $this->getTestUser('UsersControllerTest3@userscontroller.com');
$domain = $this->getTestDomain('userscontroller.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_PUBLIC,
]);
$user1->assignPackage($package_kolab);
$domain->assignPackage($package_domain, $user1);
$user1->assignPackage($package_kolab, $user2);
$user1->assignPackage($package_kolab, $user3);
$user1->wallets()->first()->addController($user2);
// TODO/FIXME:
// For now controller can delete himself, as well as
// the whole account he has control to, including the owner
// Probably he should not be able to do none of those
// However, this is not 0-regression scenario as we
// do not fully support additional controllers.
//$response = $this->actingAs($user2)->delete("api/v4/users/{$user2->id}");
//$response->assertStatus(403);
$response = $this->actingAs($user2)->delete("api/v4/users/{$user3->id}");
$response->assertStatus(200);
$response = $this->actingAs($user2)->delete("api/v4/users/{$user1->id}");
$response->assertStatus(200);
// Note: More detailed assertions in testDestroy() above
$this->assertTrue($user1->fresh()->trashed());
$this->assertTrue($user2->fresh()->trashed());
$this->assertTrue($user3->fresh()->trashed());
}
/**
* Test user listing (GET /api/v4/users)
*/
public function testIndex(): void
{
// Test unauth access
$response = $this->get("api/v4/users");
$response->assertStatus(401);
$jack = $this->getTestUser('jack@kolab.org');
$joe = $this->getTestUser('joe@kolab.org');
$john = $this->getTestUser('john@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
$response = $this->actingAs($jack)->get("/api/v4/users");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(0, $json);
$response = $this->actingAs($john)->get("/api/v4/users");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(4, $json);
$this->assertSame($jack->email, $json[0]['email']);
$this->assertSame($joe->email, $json[1]['email']);
$this->assertSame($john->email, $json[2]['email']);
$this->assertSame($ned->email, $json[3]['email']);
// Values below are tested by Unit tests
$this->assertArrayHasKey('isDeleted', $json[0]);
$this->assertArrayHasKey('isSuspended', $json[0]);
$this->assertArrayHasKey('isActive', $json[0]);
$this->assertArrayHasKey('isLdapReady', $json[0]);
$this->assertArrayHasKey('isImapReady', $json[0]);
$response = $this->actingAs($ned)->get("/api/v4/users");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(4, $json);
$this->assertSame($jack->email, $json[0]['email']);
$this->assertSame($joe->email, $json[1]['email']);
$this->assertSame($john->email, $json[2]['email']);
$this->assertSame($ned->email, $json[3]['email']);
}
/**
* Test fetching user data/profile (GET /api/v4/users/)
*/
public function testShow(): void
{
$userA = $this->getTestUser('UserEntitlement2A@UserEntitlement.com');
// Test getting profile of self
$response = $this->actingAs($userA)->get("/api/v4/users/{$userA->id}");
$json = $response->json();
$response->assertStatus(200);
$this->assertEquals($userA->id, $json['id']);
$this->assertEquals($userA->email, $json['email']);
$this->assertTrue(is_array($json['statusInfo']));
$this->assertTrue(is_array($json['settings']));
$this->assertTrue(is_array($json['aliases']));
$this->assertSame([], $json['skus']);
// Values below are tested by Unit tests
$this->assertArrayHasKey('isDeleted', $json);
$this->assertArrayHasKey('isSuspended', $json);
$this->assertArrayHasKey('isActive', $json);
$this->assertArrayHasKey('isLdapReady', $json);
$this->assertArrayHasKey('isImapReady', $json);
$john = $this->getTestUser('john@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
// Test unauthorized access to a profile of other user
$response = $this->actingAs($jack)->get("/api/v4/users/{$userA->id}");
$response->assertStatus(403);
// Test authorized access to a profile of other user
// Ned: Additional account controller
$response = $this->actingAs($ned)->get("/api/v4/users/{$john->id}");
$response->assertStatus(200);
$response = $this->actingAs($ned)->get("/api/v4/users/{$jack->id}");
$response->assertStatus(200);
// John: Account owner
$response = $this->actingAs($john)->get("/api/v4/users/{$jack->id}");
$response->assertStatus(200);
$response = $this->actingAs($john)->get("/api/v4/users/{$ned->id}");
$response->assertStatus(200);
$json = $response->json();
$storage_sku = Sku::where('title', 'storage')->first();
$groupware_sku = Sku::where('title', 'groupware')->first();
$mailbox_sku = Sku::where('title', 'mailbox')->first();
$secondfactor_sku = Sku::where('title', '2fa')->first();
$this->assertCount(5, $json['skus']);
$this->assertSame(2, $json['skus'][$storage_sku->id]['count']);
+ $this->assertSame([0,0], $json['skus'][$storage_sku->id]['costs']);
$this->assertSame(1, $json['skus'][$groupware_sku->id]['count']);
+ $this->assertSame([555], $json['skus'][$groupware_sku->id]['costs']);
$this->assertSame(1, $json['skus'][$mailbox_sku->id]['count']);
+ $this->assertSame([444], $json['skus'][$mailbox_sku->id]['costs']);
$this->assertSame(1, $json['skus'][$secondfactor_sku->id]['count']);
+ $this->assertSame([0], $json['skus'][$secondfactor_sku->id]['costs']);
}
/**
* Test fetching user status (GET /api/v4/users//status)
* and forcing setup process update (?refresh=1)
*
* @group imap
* @group dns
*/
public function testStatus(): void
{
Queue::fake();
$john = $this->getTestUser('john@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
// Test unauthorized access
$response = $this->actingAs($jack)->get("/api/v4/users/{$john->id}/status");
$response->assertStatus(403);
if ($john->isImapReady()) {
$john->status ^= User::STATUS_IMAP_READY;
$john->save();
}
// Get user status
$response = $this->actingAs($john)->get("/api/v4/users/{$john->id}/status");
$response->assertStatus(200);
$json = $response->json();
$this->assertFalse($json['isImapReady']);
$this->assertFalse($json['isReady']);
$this->assertCount(7, $json['process']);
$this->assertSame('user-imap-ready', $json['process'][2]['label']);
$this->assertSame(false, $json['process'][2]['state']);
$this->assertTrue(empty($json['status']));
$this->assertTrue(empty($json['message']));
// Make sure the domain is confirmed (other test might unset that status)
$domain = $this->getTestDomain('kolab.org');
$domain->status |= Domain::STATUS_CONFIRMED;
$domain->save();
// Now "reboot" the process and verify the user in imap synchronously
$response = $this->actingAs($john)->get("/api/v4/users/{$john->id}/status?refresh=1");
$response->assertStatus(200);
$json = $response->json();
$this->assertTrue($json['isImapReady']);
$this->assertTrue($json['isReady']);
$this->assertCount(7, $json['process']);
$this->assertSame('user-imap-ready', $json['process'][2]['label']);
$this->assertSame(true, $json['process'][2]['state']);
$this->assertSame('success', $json['status']);
$this->assertSame('Setup process finished successfully.', $json['message']);
Queue::size(1);
// Test case for when the verify job is dispatched to the worker
$john->refresh();
$john->status ^= User::STATUS_IMAP_READY;
$john->save();
\config(['imap.admin_password' => null]);
$response = $this->actingAs($john)->get("/api/v4/users/{$john->id}/status?refresh=1");
$response->assertStatus(200);
$json = $response->json();
$this->assertFalse($json['isImapReady']);
$this->assertFalse($json['isReady']);
$this->assertSame('success', $json['status']);
$this->assertSame('waiting', $json['processState']);
$this->assertSame('Setup process has been pushed. Please wait.', $json['message']);
Queue::assertPushed(\App\Jobs\User\VerifyJob::class, 1);
}
/**
* Test UsersController::statusInfo()
*/
public function testStatusInfo(): void
{
$user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$domain = $this->getTestDomain('userscontroller.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_PUBLIC,
]);
$user->created_at = Carbon::now();
$user->status = User::STATUS_NEW;
$user->save();
$result = UsersController::statusInfo($user);
$this->assertFalse($result['isReady']);
$this->assertSame([], $result['skus']);
$this->assertCount(3, $result['process']);
$this->assertSame('user-new', $result['process'][0]['label']);
$this->assertSame(true, $result['process'][0]['state']);
$this->assertSame('user-ldap-ready', $result['process'][1]['label']);
$this->assertSame(false, $result['process'][1]['state']);
$this->assertSame('user-imap-ready', $result['process'][2]['label']);
$this->assertSame(false, $result['process'][2]['state']);
$this->assertSame('running', $result['processState']);
$user->created_at = Carbon::now()->subSeconds(181);
$user->save();
$result = UsersController::statusInfo($user);
$this->assertSame('failed', $result['processState']);
$user->status |= User::STATUS_LDAP_READY | User::STATUS_IMAP_READY;
$user->save();
$result = UsersController::statusInfo($user);
$this->assertTrue($result['isReady']);
$this->assertCount(3, $result['process']);
$this->assertSame('user-new', $result['process'][0]['label']);
$this->assertSame(true, $result['process'][0]['state']);
$this->assertSame('user-ldap-ready', $result['process'][1]['label']);
$this->assertSame(true, $result['process'][1]['state']);
$this->assertSame('user-imap-ready', $result['process'][2]['label']);
$this->assertSame(true, $result['process'][2]['state']);
$this->assertSame('done', $result['processState']);
$domain->status |= Domain::STATUS_VERIFIED;
$domain->type = Domain::TYPE_EXTERNAL;
$domain->save();
$result = UsersController::statusInfo($user);
$this->assertFalse($result['isReady']);
$this->assertSame([], $result['skus']);
$this->assertCount(7, $result['process']);
$this->assertSame('user-new', $result['process'][0]['label']);
$this->assertSame(true, $result['process'][0]['state']);
$this->assertSame('user-ldap-ready', $result['process'][1]['label']);
$this->assertSame(true, $result['process'][1]['state']);
$this->assertSame('user-imap-ready', $result['process'][2]['label']);
$this->assertSame(true, $result['process'][2]['state']);
$this->assertSame('domain-new', $result['process'][3]['label']);
$this->assertSame(true, $result['process'][3]['state']);
$this->assertSame('domain-ldap-ready', $result['process'][4]['label']);
$this->assertSame(false, $result['process'][4]['state']);
$this->assertSame('domain-verified', $result['process'][5]['label']);
$this->assertSame(true, $result['process'][5]['state']);
$this->assertSame('domain-confirmed', $result['process'][6]['label']);
$this->assertSame(false, $result['process'][6]['state']);
// Test 'skus' property
$user->assignSku(Sku::where('title', 'beta')->first());
$result = UsersController::statusInfo($user);
$this->assertSame(['beta'], $result['skus']);
$user->assignSku(Sku::where('title', 'meet')->first());
$result = UsersController::statusInfo($user);
$this->assertSame(['beta', 'meet'], $result['skus']);
$user->assignSku(Sku::where('title', 'meet')->first());
$result = UsersController::statusInfo($user);
$this->assertSame(['beta', 'meet'], $result['skus']);
}
/**
* Test user creation (POST /api/v4/users)
*/
public function testStore(): void
{
Queue::fake();
$jack = $this->getTestUser('jack@kolab.org');
$john = $this->getTestUser('john@kolab.org');
$deleted_priv = $this->getTestUser('deleted@kolab.org');
$deleted_priv->delete();
// Test empty request
$response = $this->actingAs($john)->post("/api/v4/users", []);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertSame("The email field is required.", $json['errors']['email']);
$this->assertSame("The password field is required.", $json['errors']['password'][0]);
$this->assertCount(2, $json);
// Test access by user not being a wallet controller
$post = ['first_name' => 'Test'];
$response = $this->actingAs($jack)->post("/api/v4/users", $post);
$json = $response->json();
$response->assertStatus(403);
$this->assertSame('error', $json['status']);
$this->assertSame("Access denied", $json['message']);
$this->assertCount(2, $json);
// Test some invalid data
$post = ['password' => '12345678', 'email' => 'invalid'];
$response = $this->actingAs($john)->post("/api/v4/users", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json);
$this->assertSame('The password confirmation does not match.', $json['errors']['password'][0]);
$this->assertSame('The specified email is invalid.', $json['errors']['email']);
// Test existing user email
$post = [
'password' => 'simple',
'password_confirmation' => 'simple',
'first_name' => 'John2',
'last_name' => 'Doe2',
'email' => 'jack.daniels@kolab.org',
];
$response = $this->actingAs($john)->post("/api/v4/users", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json);
$this->assertSame('The specified email is not available.', $json['errors']['email']);
$package_kolab = \App\Package::where('title', 'kolab')->first();
$package_domain = \App\Package::where('title', 'domain-hosting')->first();
$post = [
'password' => 'simple',
'password_confirmation' => 'simple',
'first_name' => 'John2',
'last_name' => 'Doe2',
'email' => 'john2.doe2@kolab.org',
'organization' => 'TestOrg',
'aliases' => ['useralias1@kolab.org', 'deleted@kolab.org'],
];
// Missing package
$response = $this->actingAs($john)->post("/api/v4/users", $post);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertSame("Package is required.", $json['errors']['package']);
$this->assertCount(2, $json);
// Invalid package
$post['package'] = $package_domain->id;
$response = $this->actingAs($john)->post("/api/v4/users", $post);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertSame("Invalid package selected.", $json['errors']['package']);
$this->assertCount(2, $json);
// Test full and valid data
$post['package'] = $package_kolab->id;
$response = $this->actingAs($john)->post("/api/v4/users", $post);
$json = $response->json();
$response->assertStatus(200);
$this->assertSame('success', $json['status']);
$this->assertSame("User created successfully.", $json['message']);
$this->assertCount(2, $json);
$user = User::where('email', 'john2.doe2@kolab.org')->first();
$this->assertInstanceOf(User::class, $user);
$this->assertSame('John2', $user->getSetting('first_name'));
$this->assertSame('Doe2', $user->getSetting('last_name'));
$this->assertSame('TestOrg', $user->getSetting('organization'));
$aliases = $user->aliases()->orderBy('alias')->get();
$this->assertCount(2, $aliases);
$this->assertSame('deleted@kolab.org', $aliases[0]->alias);
$this->assertSame('useralias1@kolab.org', $aliases[1]->alias);
// Assert the new user entitlements
$this->assertUserEntitlements($user, ['groupware', 'mailbox', 'storage', 'storage']);
// Assert the wallet to which the new user should be assigned to
$wallet = $user->wallet();
$this->assertSame($john->wallets()->first()->id, $wallet->id);
// Attempt to create a user previously deleted
$user->delete();
$post['package'] = $package_kolab->id;
$post['aliases'] = [];
$response = $this->actingAs($john)->post("/api/v4/users", $post);
$json = $response->json();
$response->assertStatus(200);
$this->assertSame('success', $json['status']);
$this->assertSame("User created successfully.", $json['message']);
$this->assertCount(2, $json);
$user = User::where('email', 'john2.doe2@kolab.org')->first();
$this->assertInstanceOf(User::class, $user);
$this->assertSame('John2', $user->getSetting('first_name'));
$this->assertSame('Doe2', $user->getSetting('last_name'));
$this->assertSame('TestOrg', $user->getSetting('organization'));
$this->assertCount(0, $user->aliases()->get());
$this->assertUserEntitlements($user, ['groupware', 'mailbox', 'storage', 'storage']);
// Test acting as account controller (not owner)
$this->markTestIncomplete();
}
/**
* Test user update (PUT /api/v4/users/)
*/
public function testUpdate(): void
{
$userA = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$jack = $this->getTestUser('jack@kolab.org');
$john = $this->getTestUser('john@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
$domain = $this->getTestDomain(
'userscontroller.com',
['status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL]
);
// Test unauthorized update of other user profile
$response = $this->actingAs($jack)->get("/api/v4/users/{$userA->id}", []);
$response->assertStatus(403);
// Test authorized update of account owner by account controller
$response = $this->actingAs($ned)->get("/api/v4/users/{$john->id}", []);
$response->assertStatus(200);
// Test updating of self (empty request)
$response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", []);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame("User data updated successfully.", $json['message']);
$this->assertTrue(!empty($json['statusInfo']));
$this->assertCount(3, $json);
// Test some invalid data
$post = ['password' => '12345678', 'currency' => 'invalid'];
$response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json);
$this->assertSame('The password confirmation does not match.', $json['errors']['password'][0]);
$this->assertSame('The currency must be 3 characters.', $json['errors']['currency'][0]);
// Test full profile update including password
$post = [
'password' => 'simple',
'password_confirmation' => 'simple',
'first_name' => 'John2',
'last_name' => 'Doe2',
'organization' => 'TestOrg',
'phone' => '+123 123 123',
'external_email' => 'external@gmail.com',
'billing_address' => 'billing',
'country' => 'CH',
'currency' => 'CHF',
'aliases' => ['useralias1@' . \config('app.domain'), 'useralias2@' . \config('app.domain')]
];
$response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post);
$json = $response->json();
$response->assertStatus(200);
$this->assertSame('success', $json['status']);
$this->assertSame("User data updated successfully.", $json['message']);
$this->assertTrue(!empty($json['statusInfo']));
$this->assertCount(3, $json);
$this->assertTrue($userA->password != $userA->fresh()->password);
unset($post['password'], $post['password_confirmation'], $post['aliases']);
foreach ($post as $key => $value) {
$this->assertSame($value, $userA->getSetting($key));
}
$aliases = $userA->aliases()->orderBy('alias')->get();
$this->assertCount(2, $aliases);
$this->assertSame('useralias1@' . \config('app.domain'), $aliases[0]->alias);
$this->assertSame('useralias2@' . \config('app.domain'), $aliases[1]->alias);
// Test unsetting values
$post = [
'first_name' => '',
'last_name' => '',
'organization' => '',
'phone' => '',
'external_email' => '',
'billing_address' => '',
'country' => '',
'currency' => '',
'aliases' => ['useralias2@' . \config('app.domain')]
];
$response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post);
$json = $response->json();
$response->assertStatus(200);
$this->assertSame('success', $json['status']);
$this->assertSame("User data updated successfully.", $json['message']);
$this->assertTrue(!empty($json['statusInfo']));
$this->assertCount(3, $json);
unset($post['aliases']);
foreach ($post as $key => $value) {
$this->assertNull($userA->getSetting($key));
}
$aliases = $userA->aliases()->get();
$this->assertCount(1, $aliases);
$this->assertSame('useralias2@' . \config('app.domain'), $aliases[0]->alias);
// Test error on some invalid aliases missing password confirmation
$post = [
'password' => 'simple123',
'aliases' => [
'useralias2@' . \config('app.domain'),
'useralias1@kolab.org',
'@kolab.org',
]
];
$response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json['errors']);
$this->assertCount(2, $json['errors']['aliases']);
$this->assertSame("The specified domain is not available.", $json['errors']['aliases'][1]);
$this->assertSame("The specified alias is invalid.", $json['errors']['aliases'][2]);
$this->assertSame("The password confirmation does not match.", $json['errors']['password'][0]);
// Test authorized update of other user
$response = $this->actingAs($ned)->put("/api/v4/users/{$jack->id}", []);
$response->assertStatus(200);
$json = $response->json();
$this->assertTrue(empty($json['statusInfo']));
// TODO: Test error on aliases with invalid/non-existing/other-user's domain
// Create entitlements and additional user for following tests
$owner = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$user = $this->getTestUser('UsersControllerTest2@userscontroller.com');
$package_domain = Package::where('title', 'domain-hosting')->first();
$package_kolab = Package::where('title', 'kolab')->first();
$package_lite = Package::where('title', 'lite')->first();
$sku_mailbox = Sku::where('title', 'mailbox')->first();
$sku_storage = Sku::where('title', 'storage')->first();
$sku_groupware = Sku::where('title', 'groupware')->first();
$domain = $this->getTestDomain(
'userscontroller.com',
[
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_EXTERNAL,
]
);
$domain->assignPackage($package_domain, $owner);
$owner->assignPackage($package_kolab);
$owner->assignPackage($package_lite, $user);
// Non-controller cannot update his own entitlements
$post = ['skus' => []];
$response = $this->actingAs($user)->put("/api/v4/users/{$user->id}", $post);
$response->assertStatus(422);
// Test updating entitlements
$post = [
'skus' => [
$sku_mailbox->id => 1,
$sku_storage->id => 3,
$sku_groupware->id => 1,
],
];
$response = $this->actingAs($owner)->put("/api/v4/users/{$user->id}", $post);
$response->assertStatus(200);
$json = $response->json();
$storage_cost = $user->entitlements()
->where('sku_id', $sku_storage->id)
->orderBy('cost')
->pluck('cost')->all();
$this->assertUserEntitlements(
$user,
['groupware', 'mailbox', 'storage', 'storage', 'storage']
);
$this->assertSame([0, 0, 25], $storage_cost);
$this->assertTrue(empty($json['statusInfo']));
}
/**
* Test UsersController::updateEntitlements()
*/
public function testUpdateEntitlements(): void
{
$jane = $this->getTestUser('jane@kolabnow.com');
$kolab = \App\Package::where('title', 'kolab')->first();
$storage = \App\Sku::where('title', 'storage')->first();
$activesync = \App\Sku::where('title', 'activesync')->first();
$groupware = \App\Sku::where('title', 'groupware')->first();
$mailbox = \App\Sku::where('title', 'mailbox')->first();
// standard package, 1 mailbox, 1 groupware, 2 storage
$jane->assignPackage($kolab);
// add 2 storage, 1 activesync
$post = [
'skus' => [
$mailbox->id => 1,
$groupware->id => 1,
$storage->id => 4,
$activesync->id => 1
]
];
$response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post);
$response->assertStatus(200);
$this->assertUserEntitlements(
$jane,
[
'activesync',
'groupware',
'mailbox',
'storage',
'storage',
'storage',
'storage'
]
);
// add 2 storage, remove 1 activesync
$post = [
'skus' => [
$mailbox->id => 1,
$groupware->id => 1,
$storage->id => 6,
$activesync->id => 0
]
];
$response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post);
$response->assertStatus(200);
$this->assertUserEntitlements(
$jane,
[
'groupware',
'mailbox',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage'
]
);
// add mailbox
$post = [
'skus' => [
$mailbox->id => 2,
$groupware->id => 1,
$storage->id => 6,
$activesync->id => 0
]
];
$response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post);
$response->assertStatus(500);
$this->assertUserEntitlements(
$jane,
[
'groupware',
'mailbox',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage'
]
);
// remove mailbox
$post = [
'skus' => [
$mailbox->id => 0,
$groupware->id => 1,
$storage->id => 6,
$activesync->id => 0
]
];
$response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post);
$response->assertStatus(500);
$this->assertUserEntitlements(
$jane,
[
'groupware',
'mailbox',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage'
]
);
// less than free storage
$post = [
'skus' => [
$mailbox->id => 1,
$groupware->id => 1,
$storage->id => 1,
$activesync->id => 0
]
];
$response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post);
$response->assertStatus(200);
$this->assertUserEntitlements(
$jane,
[
'groupware',
'mailbox',
'storage',
'storage'
]
);
}
/**
* Test user data response used in show and info actions
*/
public function testUserResponse(): void
{
$provider = \config('services.payment_provider') ?: 'mollie';
$user = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
$wallet->setSettings(['mollie_id' => null, 'stripe_id' => null]);
$result = $this->invokeMethod(new UsersController(), 'userResponse', [$user]);
$this->assertEquals($user->id, $result['id']);
$this->assertEquals($user->email, $result['email']);
$this->assertEquals($user->status, $result['status']);
$this->assertTrue(is_array($result['statusInfo']));
$this->assertTrue(is_array($result['aliases']));
$this->assertCount(1, $result['aliases']);
$this->assertSame('john.doe@kolab.org', $result['aliases'][0]);
$this->assertTrue(is_array($result['settings']));
$this->assertSame('US', $result['settings']['country']);
$this->assertSame('USD', $result['settings']['currency']);
$this->assertTrue(is_array($result['accounts']));
$this->assertTrue(is_array($result['wallets']));
$this->assertCount(0, $result['accounts']);
$this->assertCount(1, $result['wallets']);
$this->assertSame($wallet->id, $result['wallet']['id']);
$this->assertArrayNotHasKey('discount', $result['wallet']);
$this->assertTrue($result['statusInfo']['enableDomains']);
$this->assertTrue($result['statusInfo']['enableWallets']);
$this->assertTrue($result['statusInfo']['enableUsers']);
// Ned is John's wallet controller
$ned = $this->getTestUser('ned@kolab.org');
$ned_wallet = $ned->wallets()->first();
$result = $this->invokeMethod(new UsersController(), 'userResponse', [$ned]);
$this->assertEquals($ned->id, $result['id']);
$this->assertEquals($ned->email, $result['email']);
$this->assertTrue(is_array($result['accounts']));
$this->assertTrue(is_array($result['wallets']));
$this->assertCount(1, $result['accounts']);
$this->assertCount(1, $result['wallets']);
$this->assertSame($wallet->id, $result['wallet']['id']);
$this->assertSame($wallet->id, $result['accounts'][0]['id']);
$this->assertSame($ned_wallet->id, $result['wallets'][0]['id']);
$this->assertSame($provider, $result['wallet']['provider']);
$this->assertSame($provider, $result['wallets'][0]['provider']);
$this->assertTrue($result['statusInfo']['enableDomains']);
$this->assertTrue($result['statusInfo']['enableWallets']);
$this->assertTrue($result['statusInfo']['enableUsers']);
// Test discount in a response
$discount = Discount::where('code', 'TEST')->first();
$wallet->discount()->associate($discount);
$wallet->save();
$mod_provider = $provider == 'mollie' ? 'stripe' : 'mollie';
$wallet->setSetting($mod_provider . '_id', 123);
$user->refresh();
$result = $this->invokeMethod(new UsersController(), 'userResponse', [$user]);
$this->assertEquals($user->id, $result['id']);
$this->assertSame($discount->id, $result['wallet']['discount_id']);
$this->assertSame($discount->discount, $result['wallet']['discount']);
$this->assertSame($discount->description, $result['wallet']['discount_description']);
$this->assertSame($mod_provider, $result['wallet']['provider']);
$this->assertSame($discount->id, $result['wallets'][0]['discount_id']);
$this->assertSame($discount->discount, $result['wallets'][0]['discount']);
$this->assertSame($discount->description, $result['wallets'][0]['discount_description']);
$this->assertSame($mod_provider, $result['wallets'][0]['provider']);
// Jack is not a John's wallet controller
$jack = $this->getTestUser('jack@kolab.org');
$result = $this->invokeMethod(new UsersController(), 'userResponse', [$jack]);
$this->assertFalse($result['statusInfo']['enableDomains']);
$this->assertFalse($result['statusInfo']['enableWallets']);
$this->assertFalse($result['statusInfo']['enableUsers']);
}
/**
* List of email address validation cases for testValidateEmail()
*
* @return array Arguments for testValidateEmail()
*/
public function dataValidateEmail(): array
{
$this->refreshApplication();
$public_domains = Domain::getPublicDomains();
$domain = reset($public_domains);
$john = $this->getTestUser('john@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
return [
// Invalid format
["$domain", $john, 'The specified email is invalid.'],
[".@$domain", $john, 'The specified email is invalid.'],
["test123456@localhost", $john, 'The specified domain is invalid.'],
["test123456@unknown-domain.org", $john, 'The specified domain is invalid.'],
["$domain", $john, 'The specified email is invalid.'],
[".@$domain", $john, 'The specified email is invalid.'],
// forbidden local part on public domains
["admin@$domain", $john, 'The specified email is not available.'],
["administrator@$domain", $john, 'The specified email is not available.'],
// forbidden (other user's domain)
["testtest@kolab.org", $user, 'The specified domain is not available.'],
// existing alias of other user, to be a user email
["jack.daniels@kolab.org", $john, 'The specified email is not available.'],
// valid (user domain)
["admin@kolab.org", $john, null],
// valid (public domain)
["test.test@$domain", $john, null],
];
}
/**
* User email address validation.
*
* Note: Technically these include unit tests, but let's keep it here for now.
* FIXME: Shall we do a http request for each case?
*
* @dataProvider dataValidateEmail
*/
public function testValidateEmail($email, $user, $expected_result): void
{
$result = UsersController::validateEmail($email, $user);
$this->assertSame($expected_result, $result);
}
/**
* User email validation - tests for $deleted argument
*
* Note: Technically these include unit tests, but let's keep it here for now.
* FIXME: Shall we do a http request for each case?
*/
public function testValidateEmailDeleted(): void
{
Queue::fake();
$john = $this->getTestUser('john@kolab.org');
$deleted_priv = $this->getTestUser('deleted@kolab.org');
$deleted_priv->delete();
$deleted_pub = $this->getTestUser('deleted@kolabnow.com');
$deleted_pub->delete();
$result = UsersController::validateEmail('deleted@kolab.org', $john, $deleted);
$this->assertSame(null, $result);
$this->assertSame($deleted_priv->id, $deleted->id);
$result = UsersController::validateEmail('deleted@kolabnow.com', $john, $deleted);
$this->assertSame('The specified email is not available.', $result);
$this->assertSame(null, $deleted);
$result = UsersController::validateEmail('jack@kolab.org', $john, $deleted);
$this->assertSame('The specified email is not available.', $result);
$this->assertSame(null, $deleted);
}
/**
* User email validation - tests for an address being a group email address
*
* Note: Technically these include unit tests, but let's keep it here for now.
* FIXME: Shall we do a http request for each case?
*/
public function testValidateEmailGroup(): void
{
Queue::fake();
$john = $this->getTestUser('john@kolab.org');
$pub_group = $this->getTestGroup('group-test@kolabnow.com');
$priv_group = $this->getTestGroup('group-test@kolab.org');
// A group in a public domain, existing
$result = UsersController::validateEmail($pub_group->email, $john, $deleted);
$this->assertSame('The specified email is not available.', $result);
$this->assertNull($deleted);
$pub_group->delete();
// A group in a public domain, deleted
$result = UsersController::validateEmail($pub_group->email, $john, $deleted);
$this->assertSame('The specified email is not available.', $result);
$this->assertNull($deleted);
// A group in a private domain, existing
$result = UsersController::validateEmail($priv_group->email, $john, $deleted);
$this->assertSame('The specified email is not available.', $result);
$this->assertNull($deleted);
$priv_group->delete();
// A group in a private domain, deleted
$result = UsersController::validateEmail($priv_group->email, $john, $deleted);
$this->assertSame(null, $result);
$this->assertSame($priv_group->id, $deleted->id);
}
/**
* List of alias validation cases for testValidateAlias()
*
* @return array Arguments for testValidateAlias()
*/
public function dataValidateAlias(): array
{
$this->refreshApplication();
$public_domains = Domain::getPublicDomains();
$domain = reset($public_domains);
$john = $this->getTestUser('john@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
return [
// Invalid format
["$domain", $john, 'The specified alias is invalid.'],
[".@$domain", $john, 'The specified alias is invalid.'],
["test123456@localhost", $john, 'The specified domain is invalid.'],
["test123456@unknown-domain.org", $john, 'The specified domain is invalid.'],
["$domain", $john, 'The specified alias is invalid.'],
[".@$domain", $john, 'The specified alias is invalid.'],
// forbidden local part on public domains
["admin@$domain", $john, 'The specified alias is not available.'],
["administrator@$domain", $john, 'The specified alias is not available.'],
// forbidden (other user's domain)
["testtest@kolab.org", $user, 'The specified domain is not available.'],
// existing alias of other user, to be an alias, user in the same group account
["jack.daniels@kolab.org", $john, null],
// existing user
["jack@kolab.org", $john, 'The specified alias is not available.'],
// valid (user domain)
["admin@kolab.org", $john, null],
// valid (public domain)
["test.test@$domain", $john, null],
];
}
/**
* User email alias validation.
*
* Note: Technically these include unit tests, but let's keep it here for now.
* FIXME: Shall we do a http request for each case?
*
* @dataProvider dataValidateAlias
*/
public function testValidateAlias($alias, $user, $expected_result): void
{
$result = UsersController::validateAlias($alias, $user);
$this->assertSame($expected_result, $result);
}
/**
* User alias validation - more cases.
*
* Note: Technically these include unit tests, but let's keep it here for now.
* FIXME: Shall we do a http request for each case?
*/
public function testValidateAlias2(): void
{
Queue::fake();
$john = $this->getTestUser('john@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$deleted_priv = $this->getTestUser('deleted@kolab.org');
$deleted_priv->setAliases(['deleted-alias@kolab.org']);
$deleted_priv->delete();
$deleted_pub = $this->getTestUser('deleted@kolabnow.com');
$deleted_pub->setAliases(['deleted-alias@kolabnow.com']);
$deleted_pub->delete();
$group = $this->getTestGroup('group-test@kolabnow.com');
// An alias that was a user email before is allowed, but only for custom domains
$result = UsersController::validateAlias('deleted@kolab.org', $john);
$this->assertSame(null, $result);
$result = UsersController::validateAlias('deleted-alias@kolab.org', $john);
$this->assertSame(null, $result);
$result = UsersController::validateAlias('deleted@kolabnow.com', $john);
$this->assertSame('The specified alias is not available.', $result);
$result = UsersController::validateAlias('deleted-alias@kolabnow.com', $john);
$this->assertSame('The specified alias is not available.', $result);
// A grpoup with the same email address exists
$result = UsersController::validateAlias($group->email, $john);
$this->assertSame('The specified alias is not available.', $result);
}
}