diff --git a/src/app/Http/Controllers/API/V4/Admin/GroupsController.php b/src/app/Http/Controllers/API/V4/Admin/GroupsController.php index f109e65d..4dc5d03b 100644 --- a/src/app/Http/Controllers/API/V4/Admin/GroupsController.php +++ b/src/app/Http/Controllers/API/V4/Admin/GroupsController.php @@ -1,118 +1,118 @@ input('search')); $owner = trim(request()->input('owner')); $result = collect([]); if ($owner) { - if ($owner = User::find($owner)) { + if ($owner = User::withEnvTenant()->find($owner)) { foreach ($owner->wallets as $wallet) { $wallet->entitlements()->where('entitleable_type', Group::class)->get() ->each(function ($entitlement) use ($result) { $result->push($entitlement->entitleable); }); } $result = $result->sortBy('namespace')->values(); } } elseif (!empty($search)) { - if ($group = Group::where('email', $search)->first()) { + if ($group = Group::withEnvTenant()->where('email', $search)->first()) { $result->push($group); } } // Process the result $result = $result->map(function ($group) { $data = [ 'id' => $group->id, 'email' => $group->email, ]; $data = array_merge($data, self::groupStatuses($group)); return $data; }); $result = [ 'list' => $result, 'count' => count($result), 'message' => \trans('app.search-foundxdistlists', ['x' => count($result)]), ]; return response()->json($result); } /** * Create a new group. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function store(Request $request) { return $this->errorResponse(404); } /** * Suspend a group * * @param \Illuminate\Http\Request $request The API request. * @param string $id Group identifier * * @return \Illuminate\Http\JsonResponse The response */ public function suspend(Request $request, $id) { - $group = Group::find($id); + $group = Group::withEnvTenant()->find($id); if (empty($group)) { return $this->errorResponse(404); } $group->suspend(); return response()->json([ 'status' => 'success', 'message' => __('app.distlist-suspend-success'), ]); } /** * Un-Suspend a group * * @param \Illuminate\Http\Request $request The API request. * @param string $id Group identifier * * @return \Illuminate\Http\JsonResponse The response */ public function unsuspend(Request $request, $id) { - $group = Group::find($id); + $group = Group::withEnvTenant()->find($id); if (empty($group)) { return $this->errorResponse(404); } $group->unsuspend(); return response()->json([ 'status' => 'success', 'message' => __('app.distlist-unsuspend-success'), ]); } } diff --git a/src/app/Http/Controllers/API/V4/GroupsController.php b/src/app/Http/Controllers/API/V4/GroupsController.php index e8c5265a..0c2e3daa 100644 --- a/src/app/Http/Controllers/API/V4/GroupsController.php +++ b/src/app/Http/Controllers/API/V4/GroupsController.php @@ -1,507 +1,507 @@ errorResponse(404); } /** * Delete a group. * * @param int $id Group identifier * * @return \Illuminate\Http\JsonResponse The response */ public function destroy($id) { - $group = Group::find($id); + $group = Group::withEnvTenant()->find($id); if (empty($group)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canDelete($group)) { return $this->errorResponse(403); } $group->delete(); return response()->json([ 'status' => 'success', 'message' => __('app.distlist-delete-success'), ]); } /** * Show the form for editing the specified group. * * @param int $id Group identifier * * @return \Illuminate\Http\JsonResponse */ public function edit($id) { return $this->errorResponse(404); } /** * Listing of groups belonging to the authenticated user. * * The group-entitlements billed to the current user wallet(s) * * @return \Illuminate\Http\JsonResponse */ public function index() { $user = $this->guard()->user(); $result = $user->groups()->orderBy('email')->get() ->map(function (Group $group) { $data = [ 'id' => $group->id, 'email' => $group->email, ]; $data = array_merge($data, self::groupStatuses($group)); return $data; }); return response()->json($result); } /** * Display information of a group specified by $id. * * @param int $id The group to show information for. * * @return \Illuminate\Http\JsonResponse */ public function show($id) { - $group = Group::find($id); + $group = Group::withEnvTenant()->find($id); if (empty($group)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($group)) { return $this->errorResponse(403); } $response = $group->toArray(); $response = array_merge($response, self::groupStatuses($group)); $response['statusInfo'] = self::statusInfo($group); return response()->json($response); } /** * Fetch group status (and reload setup process) * * @param int $id Group identifier * * @return \Illuminate\Http\JsonResponse */ public function status($id) { - $group = Group::find($id); + $group = Group::withEnvTenant()->find($id); if (empty($group)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($group)) { return $this->errorResponse(403); } $response = self::statusInfo($group); 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($group, $step['label']); if (!$exec) { if ($exec === null) { $async = true; } break; } $updated = true; } } if ($updated) { $response = self::statusInfo($group); } $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::groupStatuses($group)); return response()->json($response); } /** * Group status (extended) information * * @param \App\Group $group Group object * * @return array Status information */ public static function statusInfo(Group $group): array { $process = []; $steps = [ 'distlist-new' => true, 'distlist-ldap-ready' => $group->isLdapReady(), ]; // 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; } $domain = $group->domain(); // 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 && $group->created_at->diffInSeconds(Carbon::now()) > 180) { $state = 'failed'; } return [ 'process' => $process, 'processState' => $state, 'isReady' => $all === $checked, ]; } /** * Create a new group 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); } $email = request()->input('email'); $members = request()->input('members'); $errors = []; // Validate group address if ($error = GroupsController::validateGroupEmail($email, $owner)) { $errors['email'] = $error; } // Validate members' email addresses if (empty($members) || !is_array($members)) { $errors['members'] = \trans('validation.listmembersrequired'); } else { foreach ($members as $i => $member) { if (is_string($member) && !empty($member)) { if ($error = GroupsController::validateMemberEmail($member, $owner)) { $errors['members'][$i] = $error; } elseif (\strtolower($member) === \strtolower($email)) { $errors['members'][$i] = \trans('validation.memberislist'); } } else { unset($members[$i]); } } } if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } DB::beginTransaction(); // Create the group $group = new Group(); $group->email = $email; $group->members = $members; $group->save(); $group->assignToWallet($owner->wallets->first()); DB::commit(); return response()->json([ 'status' => 'success', 'message' => __('app.distlist-create-success'), ]); } /** * Update a group. * * @param \Illuminate\Http\Request $request The API request. * @param string $id Group identifier * * @return \Illuminate\Http\JsonResponse The response */ public function update(Request $request, $id) { - $group = Group::find($id); + $group = Group::withEnvTenant()->find($id); if (empty($group)) { return $this->errorResponse(404); } $current_user = $this->guard()->user(); if (!$current_user->canUpdate($group)) { return $this->errorResponse(403); } $owner = $group->wallet()->owner; // It is possible to update members property only for now $members = request()->input('members'); $errors = []; // Validate members' email addresses if (empty($members) || !is_array($members)) { $errors['members'] = \trans('validation.listmembersrequired'); } else { foreach ((array) $members as $i => $member) { if (is_string($member) && !empty($member)) { if ($error = GroupsController::validateMemberEmail($member, $owner)) { $errors['members'][$i] = $error; } elseif (\strtolower($member) === $group->email) { $errors['members'][$i] = \trans('validation.memberislist'); } } else { unset($members[$i]); } } } if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } $group->members = $members; $group->save(); return response()->json([ 'status' => 'success', 'message' => __('app.distlist-update-success'), ]); } /** * Execute (synchronously) specified step in a group setup process. * * @param \App\Group $group Group 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(Group $group, string $step): ?bool { try { if (strpos($step, 'domain-') === 0) { return DomainsController::execProcessStep($group->domain(), $step); } switch ($step) { case 'distlist-ldap-ready': // Group not in LDAP, create it $job = new \App\Jobs\Group\CreateJob($group->id); $job->handle(); $group->refresh(); return $group->isLdapReady(); } } catch (\Exception $e) { \Log::error($e); } return false; } /** * Prepare group statuses for the UI * * @param \App\Group $group Group object * * @return array Statuses array */ protected static function groupStatuses(Group $group): array { return [ 'isLdapReady' => $group->isLdapReady(), 'isSuspended' => $group->isSuspended(), 'isActive' => $group->isActive(), 'isDeleted' => $group->isDeleted() || $group->trashed(), ]; } /** * Validate an email address for use as a group email * * @param string $email Email address * @param \App\User $user The group owner * * @return ?string Error message on validation error */ public static function validateGroupEmail($email, \App\User $user): ?string { if (empty($email)) { return \trans('validation.required', ['attribute' => 'email']); } if (strpos($email, '@') === false) { return \trans('validation.entryinvalid', ['attribute' => 'email']); } list($login, $domain) = explode('@', \strtolower($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'); } $wallet = $domain->wallet(); // The domain must be owned by the user if (!$wallet || !$user->wallets()->find($wallet->id)) { return \trans('validation.domainnotavailable'); } // Validate login part alone $v = Validator::make( ['email' => $login], ['email' => [new \App\Rules\UserEmailLocal(true)]] ); if ($v->fails()) { return $v->errors()->toArray()['email'][0]; } // Check if a user with specified address already exists if (User::emailExists($email)) { 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']); } if (Group::emailExists($email)) { return \trans('validation.entryexists', ['attribute' => 'email']); } return null; } /** * Validate an email address for use as a group member * * @param string $email Email address * @param \App\User $user The group owner * * @return ?string Error message on validation error */ public static function validateMemberEmail($email, \App\User $user): ?string { $v = Validator::make( ['email' => $email], ['email' => [new \App\Rules\ExternalEmail()]] ); if ($v->fails()) { return $v->errors()->toArray()['email'][0]; } // A local domain user must exist if (!User::where('email', \strtolower($email))->first()) { list($login, $domain) = explode('@', \strtolower($email)); $domain = Domain::where('namespace', $domain)->first(); // We return an error only if the domain belongs to the group owner if ($domain && ($wallet = $domain->wallet()) && $user->wallets()->find($wallet->id)) { return \trans('validation.notalocaluser'); } } return null; } } diff --git a/src/app/Http/Controllers/API/V4/Reseller/GroupsController.php b/src/app/Http/Controllers/API/V4/Reseller/GroupsController.php new file mode 100644 index 00000000..20794447 --- /dev/null +++ b/src/app/Http/Controllers/API/V4/Reseller/GroupsController.php @@ -0,0 +1,7 @@ +find($allegedly_unique)) { $group->{$group->getKeyName()} = $allegedly_unique; break; } } $group->status |= Group::STATUS_NEW | Group::STATUS_ACTIVE; + + $group->tenant_id = \config('app.tenant_id'); } /** * Handle the group "created" event. * * @param \App\Group $group The group * * @return void */ public function created(Group $group) { \App\Jobs\Group\CreateJob::dispatch($group->id); } /** * Handle the group "deleting" event. * * @param \App\Group $group The group * * @return void */ public function deleting(Group $group) { // Entitlements do not have referential integrity on the entitled object, so this is our // way of doing an onDelete('cascade') without the foreign key. \App\Entitlement::where('entitleable_id', $group->id) ->where('entitleable_type', Group::class) ->delete(); } /** * Handle the group "deleted" event. * * @param \App\Group $group The group * * @return void */ public function deleted(Group $group) { if ($group->isForceDeleting()) { return; } \App\Jobs\Group\DeleteJob::dispatch($group->id); } /** * Handle the group "updated" event. * * @param \App\Group $group The group * * @return void */ public function updated(Group $group) { \App\Jobs\Group\UpdateJob::dispatch($group->id); } /** * Handle the group "restored" event. * * @param \App\Group $group The group * * @return void */ public function restored(Group $group) { // } /** * Handle the group "force deleting" event. * * @param \App\Group $group The group * * @return void */ public function forceDeleted(Group $group) { // A group can be force-deleted separately from the owner // we have to force-delete entitlements \App\Entitlement::where('entitleable_id', $group->id) ->where('entitleable_type', Group::class) ->forceDelete(); } } diff --git a/src/database/migrations/2021_05_07_150000_groups_add_tenant_id.php b/src/database/migrations/2021_05_07_150000_groups_add_tenant_id.php new file mode 100644 index 00000000..ae31b4c6 --- /dev/null +++ b/src/database/migrations/2021_05_07_150000_groups_add_tenant_id.php @@ -0,0 +1,42 @@ +bigInteger('tenant_id')->unsigned()->default(\config('app.tenant_id'))->nullable(); + + $table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('set null'); + } + ); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table( + 'groups', + function (Blueprint $table) { + $table->dropForeign(['tenant_id']); + $table->dropColumn('tenant_id'); + } + ); + } +} diff --git a/src/resources/js/reseller/routes.js b/src/resources/js/reseller/routes.js index a7afcdf4..c43dcf25 100644 --- a/src/resources/js/reseller/routes.js +++ b/src/resources/js/reseller/routes.js @@ -1,62 +1,69 @@ import DashboardComponent from '../../vue/Reseller/Dashboard' +import DistlistComponent from '../../vue/Admin/Distlist' import DomainComponent from '../../vue/Admin/Domain' import InvitationsComponent from '../../vue/Reseller/Invitations' import LoginComponent from '../../vue/Login' import LogoutComponent from '../../vue/Logout' import PageComponent from '../../vue/Page' import StatsComponent from '../../vue/Reseller/Stats' import UserComponent from '../../vue/Admin/User' const routes = [ { path: '/', redirect: { name: 'dashboard' } }, { path: '/dashboard', name: 'dashboard', component: DashboardComponent, meta: { requiresAuth: true } }, + { + path: '/distlist/:list', + name: 'distlist', + component: DistlistComponent, + meta: { requiresAuth: true } + }, { path: '/domain/:domain', name: 'domain', component: DomainComponent, meta: { requiresAuth: true } }, { path: '/login', name: 'login', component: LoginComponent }, { path: '/logout', name: 'logout', component: LogoutComponent }, { path: '/invitations', name: 'invitations', component: InvitationsComponent, meta: { requiresAuth: true } }, { path: '/stats', name: 'stats', component: StatsComponent, meta: { requiresAuth: true } }, { path: '/user/:user', name: 'user', component: UserComponent, meta: { requiresAuth: true } }, { name: '404', path: '*', component: PageComponent } ] export default routes diff --git a/src/routes/api.php b/src/routes/api.php index 29265cc5..d586e94f 100644 --- a/src/routes/api.php +++ b/src/routes/api.php @@ -1,200 +1,205 @@ 'api', 'prefix' => $prefix . 'api/auth' ], function ($router) { Route::post('login', 'API\AuthController@login'); Route::group( ['middleware' => 'auth:api'], function ($router) { Route::get('info', 'API\AuthController@info'); Route::post('logout', 'API\AuthController@logout'); Route::post('refresh', 'API\AuthController@refresh'); } ); } ); Route::group( [ 'domain' => \config('app.domain'), 'middleware' => 'api', 'prefix' => $prefix . 'api/auth' ], function ($router) { Route::post('password-reset/init', 'API\PasswordResetController@init'); Route::post('password-reset/verify', 'API\PasswordResetController@verify'); Route::post('password-reset', 'API\PasswordResetController@reset'); Route::post('signup/init', 'API\SignupController@init'); Route::get('signup/invitations/{id}', 'API\SignupController@invitation'); Route::get('signup/plans', 'API\SignupController@plans'); Route::post('signup/verify', 'API\SignupController@verify'); Route::post('signup', 'API\SignupController@signup'); } ); Route::group( [ 'domain' => \config('app.domain'), 'middleware' => 'auth:api', 'prefix' => $prefix . 'api/v4' ], function () { Route::apiResource('domains', API\V4\DomainsController::class); Route::get('domains/{id}/confirm', 'API\V4\DomainsController@confirm'); Route::get('domains/{id}/status', 'API\V4\DomainsController@status'); Route::apiResource('groups', API\V4\GroupsController::class); Route::get('groups/{id}/status', 'API\V4\GroupsController@status'); Route::apiResource('packages', API\V4\PackagesController::class); Route::apiResource('skus', API\V4\SkusController::class); Route::apiResource('users', API\V4\UsersController::class); Route::get('users/{id}/skus', 'API\V4\SkusController@userSkus'); Route::get('users/{id}/status', 'API\V4\UsersController@status'); Route::apiResource('wallets', API\V4\WalletsController::class); Route::get('wallets/{id}/transactions', 'API\V4\WalletsController@transactions'); Route::get('wallets/{id}/receipts', 'API\V4\WalletsController@receipts'); Route::get('wallets/{id}/receipts/{receipt}', 'API\V4\WalletsController@receiptDownload'); Route::post('payments', 'API\V4\PaymentsController@store'); //Route::delete('payments', 'API\V4\PaymentsController@cancel'); Route::get('payments/mandate', 'API\V4\PaymentsController@mandate'); Route::post('payments/mandate', 'API\V4\PaymentsController@mandateCreate'); Route::put('payments/mandate', 'API\V4\PaymentsController@mandateUpdate'); Route::delete('payments/mandate', 'API\V4\PaymentsController@mandateDelete'); Route::get('payments/methods', 'API\V4\PaymentsController@paymentMethods'); Route::get('payments/pending', 'API\V4\PaymentsController@payments'); Route::get('payments/has-pending', 'API\V4\PaymentsController@hasPayments'); Route::get('openvidu/rooms', 'API\V4\OpenViduController@index'); Route::post('openvidu/rooms/{id}/close', 'API\V4\OpenViduController@closeRoom'); Route::post('openvidu/rooms/{id}/config', 'API\V4\OpenViduController@setRoomConfig'); // FIXME: I'm not sure about this one, should we use DELETE request maybe? Route::post('openvidu/rooms/{id}/connections/{conn}/dismiss', 'API\V4\OpenViduController@dismissConnection'); Route::put('openvidu/rooms/{id}/connections/{conn}', 'API\V4\OpenViduController@updateConnection'); Route::post('openvidu/rooms/{id}/request/{reqid}/accept', 'API\V4\OpenViduController@acceptJoinRequest'); Route::post('openvidu/rooms/{id}/request/{reqid}/deny', 'API\V4\OpenViduController@denyJoinRequest'); } ); // Note: In Laravel 7.x we could just use withoutMiddleware() instead of a separate group Route::group( [ 'domain' => \config('app.domain'), 'prefix' => $prefix . 'api/v4' ], function () { Route::post('openvidu/rooms/{id}', 'API\V4\OpenViduController@joinRoom'); Route::post('openvidu/rooms/{id}/connections', 'API\V4\OpenViduController@createConnection'); // FIXME: I'm not sure about this one, should we use DELETE request maybe? Route::post('openvidu/rooms/{id}/connections/{conn}/dismiss', 'API\V4\OpenViduController@dismissConnection'); Route::put('openvidu/rooms/{id}/connections/{conn}', 'API\V4\OpenViduController@updateConnection'); Route::post('openvidu/rooms/{id}/request/{reqid}/accept', 'API\V4\OpenViduController@acceptJoinRequest'); Route::post('openvidu/rooms/{id}/request/{reqid}/deny', 'API\V4\OpenViduController@denyJoinRequest'); } ); Route::group( [ 'domain' => \config('app.domain'), 'middleware' => 'api', 'prefix' => $prefix . 'api/v4' ], function ($router) { Route::post('support/request', 'API\V4\SupportController@request'); } ); Route::group( [ 'domain' => \config('app.domain'), 'prefix' => $prefix . 'api/webhooks', ], function () { Route::post('payment/{provider}', 'API\V4\PaymentsController@webhook'); Route::post('meet/openvidu', 'API\V4\OpenViduController@webhook'); } ); Route::group( [ 'domain' => 'admin.' . \config('app.domain'), 'middleware' => ['auth:api', 'admin'], 'prefix' => $prefix . 'api/v4', ], function () { Route::apiResource('domains', API\V4\Admin\DomainsController::class); Route::post('domains/{id}/suspend', 'API\V4\Admin\DomainsController@suspend'); Route::post('domains/{id}/unsuspend', 'API\V4\Admin\DomainsController@unsuspend'); Route::apiResource('groups', API\V4\Admin\GroupsController::class); Route::post('groups/{id}/suspend', 'API\V4\Admin\GroupsController@suspend'); Route::post('groups/{id}/unsuspend', 'API\V4\Admin\GroupsController@unsuspend'); Route::apiResource('packages', API\V4\Admin\PackagesController::class); Route::apiResource('skus', API\V4\Admin\SkusController::class); Route::apiResource('users', API\V4\Admin\UsersController::class); Route::post('users/{id}/reset2FA', 'API\V4\Admin\UsersController@reset2FA'); Route::get('users/{id}/skus', 'API\V4\Admin\SkusController@userSkus'); Route::post('users/{id}/suspend', 'API\V4\Admin\UsersController@suspend'); Route::post('users/{id}/unsuspend', 'API\V4\Admin\UsersController@unsuspend'); Route::apiResource('wallets', API\V4\Admin\WalletsController::class); Route::post('wallets/{id}/one-off', 'API\V4\Admin\WalletsController@oneOff'); Route::get('wallets/{id}/transactions', 'API\V4\Admin\WalletsController@transactions'); Route::apiResource('discounts', API\V4\Admin\DiscountsController::class); Route::get('stats/chart/{chart}', 'API\V4\Admin\StatsController@chart'); } ); Route::group( [ 'domain' => 'reseller.' . \config('app.domain'), 'middleware' => ['auth:api', 'reseller'], 'prefix' => $prefix . 'api/v4', ], function () { Route::apiResource('domains', API\V4\Reseller\DomainsController::class); Route::post('domains/{id}/suspend', 'API\V4\Reseller\DomainsController@suspend'); Route::post('domains/{id}/unsuspend', 'API\V4\Reseller\DomainsController@unsuspend'); + + Route::apiResource('groups', API\V4\Reseller\GroupsController::class); + Route::post('groups/{id}/suspend', 'API\V4\Reseller\GroupsController@suspend'); + Route::post('groups/{id}/unsuspend', 'API\V4\Reseller\GroupsController@unsuspend'); + Route::apiResource('invitations', API\V4\Reseller\InvitationsController::class); Route::post('invitations/{id}/resend', 'API\V4\Reseller\InvitationsController@resend'); Route::apiResource('packages', API\V4\Reseller\PackagesController::class); Route::apiResource('skus', API\V4\Reseller\SkusController::class); Route::apiResource('users', API\V4\Reseller\UsersController::class); Route::post('users/{id}/reset2FA', 'API\V4\Reseller\UsersController@reset2FA'); Route::get('users/{id}/skus', 'API\V4\Reseller\SkusController@userSkus'); Route::post('users/{id}/suspend', 'API\V4\Reseller\UsersController@suspend'); Route::post('users/{id}/unsuspend', 'API\V4\Reseller\UsersController@unsuspend'); Route::apiResource('wallets', API\V4\Reseller\WalletsController::class); Route::post('wallets/{id}/one-off', 'API\V4\Reseller\WalletsController@oneOff'); Route::get('wallets/{id}/transactions', 'API\V4\Reseller\WalletsController@transactions'); Route::apiResource('discounts', API\V4\Reseller\DiscountsController::class); Route::get('stats/chart/{chart}', 'API\V4\Reseller\StatsController@chart'); } ); diff --git a/src/tests/Browser/Reseller/DistlistTest.php b/src/tests/Browser/Reseller/DistlistTest.php new file mode 100644 index 00000000..b45f8c89 --- /dev/null +++ b/src/tests/Browser/Reseller/DistlistTest.php @@ -0,0 +1,128 @@ +deleteTestGroup('group-test@kolab.org'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestGroup('group-test@kolab.org'); + + parent::tearDown(); + } + + /** + * Test distlist info page (unauthenticated) + */ + public function testDistlistUnauth(): void + { + // Test that the page requires authentication + $this->browse(function (Browser $browser) { + $user = $this->getTestUser('john@kolab.org'); + $group = $this->getTestGroup('group-test@kolab.org'); + $group->assignToWallet($user->wallets->first()); + + $browser->visit('/distlist/' . $group->id)->on(new Home()); + }); + } + + /** + * Test distribution list info page + */ + public function testInfo(): void + { + Queue::fake(); + + $this->browse(function (Browser $browser) { + $user = $this->getTestUser('john@kolab.org'); + $group = $this->getTestGroup('group-test@kolab.org'); + $group->assignToWallet($user->wallets->first()); + $group->members = ['test1@gmail.com', 'test2@gmail.com']; + $group->save(); + + $distlist_page = new DistlistPage($group->id); + $user_page = new UserPage($user->id); + + // Goto the distlist page + $browser->visit(new Home()) + ->submitLogon('reseller@kolabnow.com', 'reseller', true) + ->on(new Dashboard()) + ->visit($user_page) + ->on($user_page) + ->click('@nav #tab-distlists') + ->pause(1000) + ->click('@user-distlists table tbody tr:first-child td a') + ->on($distlist_page) + ->assertSeeIn('@distlist-info .card-title', $group->email) + ->with('@distlist-info form', function (Browser $browser) use ($group) { + $browser->assertElementsCount('.row', 3) + ->assertSeeIn('.row:nth-child(1) label', 'ID (Created at)') + ->assertSeeIn('.row:nth-child(1) #distlistid', "{$group->id} ({$group->created_at})") + ->assertSeeIn('.row:nth-child(2) label', 'Status') + ->assertSeeIn('.row:nth-child(2) #status.text-danger', 'Not Ready') + ->assertSeeIn('.row:nth-child(3) label', 'Recipients') + ->assertSeeIn('.row:nth-child(3) #members', $group->members[0]) + ->assertSeeIn('.row:nth-child(3) #members', $group->members[1]); + }); + + // Test invalid group identifier + $browser->visit('/distlist/abc')->assertErrorPage(404); + }); + } + + /** + * Test suspending/unsuspending a distribution list + * + * @depends testInfo + */ + public function testSuspendAndUnsuspend(): void + { + Queue::fake(); + + $this->browse(function (Browser $browser) { + $user = $this->getTestUser('john@kolab.org'); + $group = $this->getTestGroup('group-test@kolab.org'); + $group->assignToWallet($user->wallets->first()); + $group->status = Group::STATUS_ACTIVE | Group::STATUS_LDAP_READY; + $group->save(); + + $browser->visit(new DistlistPage($group->id)) + ->assertVisible('@distlist-info #button-suspend') + ->assertMissing('@distlist-info #button-unsuspend') + ->assertSeeIn('@distlist-info #status.text-success', 'Active') + ->click('@distlist-info #button-suspend') + ->assertToast(Toast::TYPE_SUCCESS, 'Distribution list suspended successfully.') + ->assertSeeIn('@distlist-info #status.text-warning', 'Suspended') + ->assertMissing('@distlist-info #button-suspend') + ->click('@distlist-info #button-unsuspend') + ->assertToast(Toast::TYPE_SUCCESS, 'Distribution list unsuspended successfully.') + ->assertSeeIn('@distlist-info #status.text-success', 'Active') + ->assertVisible('@distlist-info #button-suspend') + ->assertMissing('@distlist-info #button-unsuspend'); + }); + } +} diff --git a/src/tests/Browser/Reseller/UserTest.php b/src/tests/Browser/Reseller/UserTest.php index fcee3987..53ce54de 100644 --- a/src/tests/Browser/Reseller/UserTest.php +++ b/src/tests/Browser/Reseller/UserTest.php @@ -1,443 +1,473 @@ 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(); + $this->deleteTestGroup('group-test@kolab.org'); $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(); + $this->deleteTestGroup('group-test@kolab.org'); $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('reseller@kolabnow.com', 'reseller', 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); + ->assertElementsCount('@nav a', 6); // 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.'); }); + + // Assert Distribution lists tab + $browser->assertSeeIn('@nav #tab-distlists', 'Distribution lists (0)') + ->click('@nav #tab-distlists') + ->with('@user-distlists', function (Browser $browser) { + $browser->assertElementsCount('table tbody tr', 0) + ->assertSeeIn('table tfoot tr td', 'There are no distribution lists 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(); + $group = $this->getTestGroup('group-test@kolab.org'); + $group->assignToWallet($john->wallets->first()); // 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); + ->assertElementsCount('@nav a', 6); // 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 Distribution lists tab + $browser->assertSeeIn('@nav #tab-distlists', 'Distribution lists (1)') + ->click('@nav #tab-distlists') + ->with('@user-distlists table', function (Browser $browser) { + $browser->assertElementsCount('tbody tr', 1) + ->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'group-test@kolab.org') + ->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-danger') + ->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'); $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); + ->assertElementsCount('@nav a', 6); // 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)') ->click('@nav #tab-subscriptions') ->with('@user-subscriptions', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 5) ->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¹') ->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¹') ->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.'); }); + + // We don't expect John's distribution lists here + $browser->assertSeeIn('@nav #tab-distlists', 'Distribution lists (0)') + ->click('@nav #tab-distlists') + ->with('@user-distlists', function (Browser $browser) { + $browser->assertElementsCount('table tbody tr', 0) + ->assertSeeIn('table tfoot tr td', 'There are no distribution lists 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/Feature/Controller/Admin/GroupsTest.php b/src/tests/Feature/Controller/Admin/GroupsTest.php index 2e886d16..febdb014 100644 --- a/src/tests/Feature/Controller/Admin/GroupsTest.php +++ b/src/tests/Feature/Controller/Admin/GroupsTest.php @@ -1,184 +1,225 @@ deleteTestGroup('group-test@kolab.org'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestGroup('group-test@kolab.org'); parent::tearDown(); } /** * Test groups searching (/api/v4/groups) */ public function testIndex(): void { $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $group = $this->getTestGroup('group-test@kolab.org'); $group->assignToWallet($user->wallets->first()); // Non-admin user $response = $this->actingAs($user)->get("api/v4/groups"); $response->assertStatus(403); // Search with no search criteria $response = $this->actingAs($admin)->get("api/v4/groups"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); // Search with no matches expected $response = $this->actingAs($admin)->get("api/v4/groups?search=john@kolab.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); // Search by email $response = $this->actingAs($admin)->get("api/v4/groups?search={$group->email}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($group->email, $json['list'][0]['email']); // Search by owner $response = $this->actingAs($admin)->get("api/v4/groups?owner={$user->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($group->email, $json['list'][0]['email']); // Search by owner (Ned is a controller on John's wallets, // here we expect only domains assigned to Ned's wallet(s)) $ned = $this->getTestUser('ned@kolab.org'); $response = $this->actingAs($admin)->get("api/v4/groups?owner={$ned->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertCount(0, $json['list']); } + /** + * Test fetching group info + */ + public function testShow(): void + { + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $user = $this->getTestUser('test1@domainscontroller.com'); + $group = $this->getTestGroup('group-test@kolab.org'); + $group->assignToWallet($user->wallets->first()); + + // Only admins can access it + $response = $this->actingAs($user)->get("api/v4/groups/{$group->id}"); + $response->assertStatus(403); + + $response = $this->actingAs($admin)->get("api/v4/groups/{$group->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertEquals($group->id, $json['id']); + $this->assertEquals($group->email, $json['email']); + $this->assertEquals($group->status, $json['status']); + } + + /** + * Test fetching domain status (GET /api/v4/domains//status) + */ + public function testStatus(): void + { + Queue::fake(); // disable jobs + + $user = $this->getTestUser('john@kolab.org'); + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $group = $this->getTestGroup('group-test@kolab.org'); + $group->assignToWallet($user->wallets->first()); + + // This end-point does not exist for admins + $response = $this->actingAs($admin)->get("/api/v4/groups/{$group->id}/status"); + $response->assertStatus(404); + } + /** * Test group creating (POST /api/v4/groups) */ public function testStore(): void { $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); // Test unauthorized access to admin API $response = $this->actingAs($user)->post("/api/v4/groups", []); $response->assertStatus(403); // Admin can't create groups $response = $this->actingAs($admin)->post("/api/v4/groups", []); $response->assertStatus(404); } /** * Test group suspending (POST /api/v4/groups//suspend) */ public function testSuspend(): void { Queue::fake(); // disable jobs $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $group = $this->getTestGroup('group-test@kolab.org'); $group->assignToWallet($user->wallets->first()); // Test unauthorized access to admin API $response = $this->actingAs($user)->post("/api/v4/groups/{$group->id}/suspend", []); $response->assertStatus(403); // Test non-existing group ID $response = $this->actingAs($admin)->post("/api/v4/groups/abc/suspend", []); $response->assertStatus(404); $this->assertFalse($group->fresh()->isSuspended()); // Test suspending the group $response = $this->actingAs($admin)->post("/api/v4/groups/{$group->id}/suspend", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("Distribution list suspended successfully.", $json['message']); $this->assertCount(2, $json); $this->assertTrue($group->fresh()->isSuspended()); } /** * Test user un-suspending (POST /api/v4/users//unsuspend) */ public function testUnsuspend(): void { Queue::fake(); // disable jobs $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $group = $this->getTestGroup('group-test@kolab.org'); $group->assignToWallet($user->wallets->first()); $group->status |= Group::STATUS_SUSPENDED; $group->save(); // Test unauthorized access to admin API $response = $this->actingAs($user)->post("/api/v4/groups/{$group->id}/unsuspend", []); $response->assertStatus(403); // Invalid group ID $response = $this->actingAs($admin)->post("/api/v4/groups/abc/unsuspend", []); $response->assertStatus(404); $this->assertTrue($group->fresh()->isSuspended()); // Test suspending the group $response = $this->actingAs($admin)->post("/api/v4/groups/{$group->id}/unsuspend", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("Distribution list unsuspended successfully.", $json['message']); $this->assertCount(2, $json); $this->assertFalse($group->fresh()->isSuspended()); } } diff --git a/src/tests/Feature/Controller/Admin/SkusTest.php b/src/tests/Feature/Controller/Admin/SkusTest.php index 1cb2b6cb..98d0ce84 100644 --- a/src/tests/Feature/Controller/Admin/SkusTest.php +++ b/src/tests/Feature/Controller/Admin/SkusTest.php @@ -1,94 +1,94 @@ clearBetaEntitlements(); $this->clearMeetEntitlements(); } /** * {@inheritDoc} */ public function tearDown(): void { $this->clearBetaEntitlements(); $this->clearMeetEntitlements(); parent::tearDown(); } /** * Test fetching SKUs list */ public function testIndex(): void { $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $user = $this->getTestUser('john@kolab.org'); $sku = Sku::where('title', 'mailbox')->first(); // Unauth access not allowed $response = $this->get("api/v4/skus"); $response->assertStatus(401); // User access not allowed on admin API $response = $this->actingAs($user)->get("api/v4/skus"); $response->assertStatus(403); $response = $this->actingAs($admin)->get("api/v4/skus"); $response->assertStatus(200); $json = $response->json(); - $this->assertCount(8, $json); + $this->assertCount(9, $json); $this->assertSame(100, $json[0]['prio']); $this->assertSame($sku->id, $json[0]['id']); $this->assertSame($sku->title, $json[0]['title']); $this->assertSame($sku->name, $json[0]['name']); $this->assertSame($sku->description, $json[0]['description']); $this->assertSame($sku->cost, $json[0]['cost']); $this->assertSame($sku->units_free, $json[0]['units_free']); $this->assertSame($sku->period, $json[0]['period']); $this->assertSame($sku->active, $json[0]['active']); $this->assertSame('user', $json[0]['type']); $this->assertSame('mailbox', $json[0]['handler']); } /** * Test fetching SKUs list for a user (GET /users//skus) */ public function testUserSkus(): void { $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $user = $this->getTestUser('john@kolab.org'); // Unauth access not allowed $response = $this->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(401); // Non-admin access not allowed $response = $this->actingAs($user)->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(403); $response = $this->actingAs($admin)->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(8, $json); // Note: Details are tested where we test API\V4\SkusController } } diff --git a/src/tests/Feature/Controller/Admin/GroupsTest.php b/src/tests/Feature/Controller/Reseller/GroupsTest.php similarity index 50% copy from src/tests/Feature/Controller/Admin/GroupsTest.php copy to src/tests/Feature/Controller/Reseller/GroupsTest.php index 2e886d16..f3103f04 100644 --- a/src/tests/Feature/Controller/Admin/GroupsTest.php +++ b/src/tests/Feature/Controller/Reseller/GroupsTest.php @@ -1,184 +1,291 @@ deleteTestGroup('group-test@kolab.org'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestGroup('group-test@kolab.org'); parent::tearDown(); } /** * Test groups searching (/api/v4/groups) */ public function testIndex(): void { $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $reseller1 = $this->getTestUser('reseller@kolabnow.com'); + $reseller2 = $this->getTestUser('reseller@reseller.com'); $group = $this->getTestGroup('group-test@kolab.org'); $group->assignToWallet($user->wallets->first()); // Non-admin user $response = $this->actingAs($user)->get("api/v4/groups"); $response->assertStatus(403); - // Search with no search criteria + // Admin user $response = $this->actingAs($admin)->get("api/v4/groups"); + $response->assertStatus(403); + + // Reseller from a different tenant + $response = $this->actingAs($reseller2)->get("api/v4/groups"); + $response->assertStatus(403); + + // Search with no search criteria + $response = $this->actingAs($reseller1)->get("api/v4/groups"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); // Search with no matches expected - $response = $this->actingAs($admin)->get("api/v4/groups?search=john@kolab.org"); + $response = $this->actingAs($reseller1)->get("api/v4/groups?search=john@kolab.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); // Search by email - $response = $this->actingAs($admin)->get("api/v4/groups?search={$group->email}"); + $response = $this->actingAs($reseller1)->get("api/v4/groups?search={$group->email}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($group->email, $json['list'][0]['email']); // Search by owner - $response = $this->actingAs($admin)->get("api/v4/groups?owner={$user->id}"); + $response = $this->actingAs($reseller1)->get("api/v4/groups?owner={$user->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($group->email, $json['list'][0]['email']); // Search by owner (Ned is a controller on John's wallets, // here we expect only domains assigned to Ned's wallet(s)) $ned = $this->getTestUser('ned@kolab.org'); - $response = $this->actingAs($admin)->get("api/v4/groups?owner={$ned->id}"); + $response = $this->actingAs($reseller1)->get("api/v4/groups?owner={$ned->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertCount(0, $json['list']); + + // Test unauth access to other tenant's groups + \config(['app.tenant_id' => 2]); + + $response = $this->actingAs($reseller2)->get("api/v4/groups?search=kolab.org"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(0, $json['count']); + $this->assertSame([], $json['list']); + + $response = $this->actingAs($reseller2)->get("api/v4/groups?owner={$user->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(0, $json['count']); + $this->assertSame([], $json['list']); + } + + /** + * Test fetching group info + */ + public function testShow(): void + { + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $user = $this->getTestUser('test1@domainscontroller.com'); + $reseller1 = $this->getTestUser('reseller@kolabnow.com'); + $reseller2 = $this->getTestUser('reseller@reseller.com'); + $group = $this->getTestGroup('group-test@kolab.org'); + $group->assignToWallet($user->wallets->first()); + + // Only resellers can access it + $response = $this->actingAs($user)->get("api/v4/groups/{$group->id}"); + $response->assertStatus(403); + + $response = $this->actingAs($admin)->get("api/v4/groups/{$group->id}"); + $response->assertStatus(403); + + $response = $this->actingAs($reseller2)->get("api/v4/groups/{$group->id}"); + $response->assertStatus(403); + + $response = $this->actingAs($reseller1)->get("api/v4/groups/{$group->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertEquals($group->id, $json['id']); + $this->assertEquals($group->email, $json['email']); + $this->assertEquals($group->status, $json['status']); + } + + /** + * Test fetching group status (GET /api/v4/domains//status) + */ + public function testStatus(): void + { + Queue::fake(); // disable jobs + + $user = $this->getTestUser('john@kolab.org'); + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $reseller1 = $this->getTestUser('reseller@kolabnow.com'); + $group = $this->getTestGroup('group-test@kolab.org'); + $group->assignToWallet($user->wallets->first()); + + // This end-point does not exist for admins + $response = $this->actingAs($reseller1)->get("/api/v4/groups/{$group->id}/status"); + $response->assertStatus(404); } /** * Test group creating (POST /api/v4/groups) */ public function testStore(): void { $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $reseller1 = $this->getTestUser('reseller@kolabnow.com'); - // Test unauthorized access to admin API + // Test unauthorized access to reseller API $response = $this->actingAs($user)->post("/api/v4/groups", []); $response->assertStatus(403); - // Admin can't create groups + // Reseller or admin can't create groups $response = $this->actingAs($admin)->post("/api/v4/groups", []); + $response->assertStatus(403); + + $response = $this->actingAs($reseller1)->post("/api/v4/groups", []); $response->assertStatus(404); } /** * Test group suspending (POST /api/v4/groups//suspend) */ public function testSuspend(): void { Queue::fake(); // disable jobs $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $reseller1 = $this->getTestUser('reseller@kolabnow.com'); + $reseller2 = $this->getTestUser('reseller@reseller.com'); $group = $this->getTestGroup('group-test@kolab.org'); $group->assignToWallet($user->wallets->first()); - // Test unauthorized access to admin API + // Test unauthorized access to reseller API $response = $this->actingAs($user)->post("/api/v4/groups/{$group->id}/suspend", []); $response->assertStatus(403); + // Test unauthorized access to reseller API + $response = $this->actingAs($admin)->post("/api/v4/groups/{$group->id}/suspend", []); + $response->assertStatus(403); + // Test non-existing group ID - $response = $this->actingAs($admin)->post("/api/v4/groups/abc/suspend", []); + $response = $this->actingAs($reseller1)->post("/api/v4/groups/abc/suspend", []); $response->assertStatus(404); $this->assertFalse($group->fresh()->isSuspended()); // Test suspending the group - $response = $this->actingAs($admin)->post("/api/v4/groups/{$group->id}/suspend", []); + $response = $this->actingAs($reseller1)->post("/api/v4/groups/{$group->id}/suspend", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("Distribution list suspended successfully.", $json['message']); $this->assertCount(2, $json); $this->assertTrue($group->fresh()->isSuspended()); + + // Test unauth access to other tenant's groups + \config(['app.tenant_id' => 2]); + + $response = $this->actingAs($reseller2)->post("/api/v4/groups/{$group->id}/suspend", []); + $response->assertStatus(404); } /** * Test user un-suspending (POST /api/v4/users//unsuspend) */ public function testUnsuspend(): void { Queue::fake(); // disable jobs $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $reseller1 = $this->getTestUser('reseller@kolabnow.com'); + $reseller2 = $this->getTestUser('reseller@reseller.com'); $group = $this->getTestGroup('group-test@kolab.org'); $group->assignToWallet($user->wallets->first()); $group->status |= Group::STATUS_SUSPENDED; $group->save(); - // Test unauthorized access to admin API + // Test unauthorized access to reseller API $response = $this->actingAs($user)->post("/api/v4/groups/{$group->id}/unsuspend", []); $response->assertStatus(403); + // Test unauthorized access to reseller API + $response = $this->actingAs($admin)->post("/api/v4/groups/{$group->id}/unsuspend", []); + $response->assertStatus(403); + // Invalid group ID - $response = $this->actingAs($admin)->post("/api/v4/groups/abc/unsuspend", []); + $response = $this->actingAs($reseller1)->post("/api/v4/groups/abc/unsuspend", []); $response->assertStatus(404); $this->assertTrue($group->fresh()->isSuspended()); // Test suspending the group - $response = $this->actingAs($admin)->post("/api/v4/groups/{$group->id}/unsuspend", []); + $response = $this->actingAs($reseller1)->post("/api/v4/groups/{$group->id}/unsuspend", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("Distribution list unsuspended successfully.", $json['message']); $this->assertCount(2, $json); $this->assertFalse($group->fresh()->isSuspended()); + + // Test unauth access to other tenant's groups + \config(['app.tenant_id' => 2]); + + $response = $this->actingAs($reseller2)->post("/api/v4/groups/{$group->id}/unsuspend", []); + $response->assertStatus(404); } } diff --git a/src/tests/Feature/Controller/Reseller/SkusTest.php b/src/tests/Feature/Controller/Reseller/SkusTest.php index a4c8c960..693ba5c5 100644 --- a/src/tests/Feature/Controller/Reseller/SkusTest.php +++ b/src/tests/Feature/Controller/Reseller/SkusTest.php @@ -1,122 +1,122 @@ 1]); $this->clearBetaEntitlements(); $this->clearMeetEntitlements(); } /** * {@inheritDoc} */ public function tearDown(): void { \config(['app.tenant_id' => 1]); $this->clearBetaEntitlements(); $this->clearMeetEntitlements(); parent::tearDown(); } /** * Test fetching SKUs list */ public function testIndex(): void { $reseller1 = $this->getTestUser('reseller@kolabnow.com'); $reseller2 = $this->getTestUser('reseller@reseller.com'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $user = $this->getTestUser('john@kolab.org'); $sku = Sku::where('title', 'mailbox')->first(); // Unauth access not allowed $response = $this->get("api/v4/skus"); $response->assertStatus(401); // User access not allowed on admin API $response = $this->actingAs($user)->get("api/v4/skus"); $response->assertStatus(403); // Admin access not allowed $response = $this->actingAs($admin)->get("api/v4/skus"); $response->assertStatus(403); $response = $this->actingAs($reseller1)->get("api/v4/skus"); $response->assertStatus(200); $json = $response->json(); - $this->assertCount(8, $json); + $this->assertCount(9, $json); $this->assertSame(100, $json[0]['prio']); $this->assertSame($sku->id, $json[0]['id']); $this->assertSame($sku->title, $json[0]['title']); $this->assertSame($sku->name, $json[0]['name']); $this->assertSame($sku->description, $json[0]['description']); $this->assertSame($sku->cost, $json[0]['cost']); $this->assertSame($sku->units_free, $json[0]['units_free']); $this->assertSame($sku->period, $json[0]['period']); $this->assertSame($sku->active, $json[0]['active']); $this->assertSame('user', $json[0]['type']); $this->assertSame('mailbox', $json[0]['handler']); // TODO: Test limiting SKUs to the tenant's SKUs } /** * Test fetching SKUs list for a user (GET /users//skus) */ public function testUserSkus(): void { $reseller1 = $this->getTestUser('reseller@kolabnow.com'); $reseller2 = $this->getTestUser('reseller@reseller.com'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $user = $this->getTestUser('john@kolab.org'); // Unauth access not allowed $response = $this->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(401); // User access not allowed $response = $this->actingAs($user)->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(403); // Admin access not allowed $response = $this->actingAs($admin)->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(403); // Reseller from another tenant not allowed $response = $this->actingAs($reseller2)->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(403); // Reseller access $response = $this->actingAs($reseller1)->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(8, $json); // Note: Details are tested where we test API\V4\SkusController // Reseller from another tenant not allowed \config(['app.tenant_id' => 2]); $response = $this->actingAs($reseller2)->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(404); } }