diff --git a/src/app/Domain.php b/src/app/Domain.php index ece91c56..ed292a0c 100644 --- a/src/app/Domain.php +++ b/src/app/Domain.php @@ -1,406 +1,406 @@ isPublic()) { return $this; } // See if this domain is already owned by another user. $wallet = $this->wallet(); if ($wallet) { \Log::error( "Domain {$this->namespace} is already assigned to {$wallet->owner->email}" ); return $this; } $wallet_id = $user->wallets()->first()->id; foreach ($package->skus as $sku) { for ($i = $sku->pivot->qty; $i > 0; $i--) { \App\Entitlement::create( [ 'wallet_id' => $wallet_id, 'sku_id' => $sku->id, 'cost' => $sku->pivot->cost(), 'entitleable_id' => $this->id, 'entitleable_type' => Domain::class ] ); } } return $this; } public function entitlement() { return $this->morphOne('App\Entitlement', 'entitleable'); } /** * Return list of public+active domain names */ public static function getPublicDomains(): array { $where = sprintf('(type & %s)', Domain::TYPE_PUBLIC); return self::whereRaw($where)->get(['namespace'])->pluck('namespace')->toArray(); } /** * Returns whether this domain is active. * * @return bool */ public function isActive(): bool { return ($this->status & self::STATUS_ACTIVE) > 0; } /** * Returns whether this domain is confirmed the ownership of. * * @return bool */ public function isConfirmed(): bool { return ($this->status & self::STATUS_CONFIRMED) > 0; } /** * Returns whether this domain is deleted. * * @return bool */ public function isDeleted(): bool { return ($this->status & self::STATUS_DELETED) > 0; } /** * Returns whether this domain is registered with us. * * @return bool */ public function isExternal(): bool { return ($this->type & self::TYPE_EXTERNAL) > 0; } /** * Returns whether this domain is hosted with us. * * @return bool */ public function isHosted(): bool { return ($this->type & self::TYPE_HOSTED) > 0; } /** * Returns whether this domain is new. * * @return bool */ public function isNew(): bool { return ($this->status & self::STATUS_NEW) > 0; } /** * Returns whether this domain is public. * * @return bool */ public function isPublic(): bool { return ($this->type & self::TYPE_PUBLIC) > 0; } /** * Returns whether this domain is registered in LDAP. * * @return bool */ public function isLdapReady(): bool { return ($this->status & self::STATUS_LDAP_READY) > 0; } /** * Returns whether this domain is suspended. * * @return bool */ public function isSuspended(): bool { return ($this->status & self::STATUS_SUSPENDED) > 0; } /** * Returns whether this (external) domain has been verified * to exist in DNS. * * @return bool */ public function isVerified(): bool { return ($this->status & self::STATUS_VERIFIED) > 0; } /** * Ensure the namespace is appropriately cased. */ public function setNamespaceAttribute($namespace) { $this->attributes['namespace'] = strtolower($namespace); } /** * Domain status mutator * * @throws \Exception */ public function setStatusAttribute($status) { $new_status = 0; $allowed_values = [ self::STATUS_NEW, self::STATUS_ACTIVE, self::STATUS_CONFIRMED, self::STATUS_SUSPENDED, self::STATUS_DELETED, self::STATUS_LDAP_READY, self::STATUS_VERIFIED, ]; foreach ($allowed_values as $value) { if ($status & $value) { $new_status |= $value; $status ^= $value; } } if ($status > 0) { throw new \Exception("Invalid domain status: {$status}"); } $this->attributes['status'] = $new_status; } /** * Ownership verification by checking for a TXT (or CNAME) record * in the domain's DNS (that matches the verification hash). * * @return bool True if verification was successful, false otherwise * @throws \Exception Throws exception on DNS or DB errors */ public function confirm(): bool { if ($this->isConfirmed()) { return true; } $hash = $this->hash(self::HASH_TEXT); $confirmed = false; // Get DNS records and find a matching TXT entry $records = \dns_get_record($this->namespace, DNS_TXT); if ($records === false) { throw new \Exception("Failed to get DNS record for {$this->namespace}"); } foreach ($records as $record) { if ($record['txt'] === $hash) { $confirmed = true; break; } } // Get DNS records and find a matching CNAME entry // Note: some servers resolve every non-existing name // so we need to define left and right side of the CNAME record // i.e.: kolab-verify IN CNAME .domain.tld. if (!$confirmed) { $cname = $this->hash(self::HASH_CODE) . '.' . $this->namespace; $records = \dns_get_record('kolab-verify.' . $this->namespace, DNS_CNAME); if ($records === false) { throw new \Exception("Failed to get DNS record for {$this->namespace}"); } foreach ($records as $records) { if ($records['target'] === $cname) { $confirmed = true; break; } } } if ($confirmed) { $this->status |= Domain::STATUS_CONFIRMED; $this->save(); } return $confirmed; } /** * Generate a verification hash for this domain * * @param int $mod One of: HASH_CNAME, HASH_CODE (Default), HASH_TEXT * * @return string Verification hash */ public function hash($mod = null): string { $cname = 'kolab-verify'; if ($mod === self::HASH_CNAME) { return $cname; } $hash = \md5('hkccp-verify-' . $this->namespace); return $mod === self::HASH_TEXT ? "$cname=$hash" : $hash; } /** * Suspend this domain. * * @return void */ public function suspend(): void { if ($this->isSuspended()) { return; } $this->status |= Domain::STATUS_SUSPENDED; $this->save(); } /** * Unsuspend this domain. * * @return void */ public function unsuspend(): void { if (!$this->isSuspended()) { return; } $this->status ^= Domain::STATUS_SUSPENDED; $this->save(); } /** * Verify if a domain exists in DNS * * @return bool True if registered, False otherwise * @throws \Exception Throws exception on DNS or DB errors */ public function verify(): bool { if ($this->isVerified()) { return true; } $records = \dns_get_record($this->namespace, DNS_ANY); if ($records === false) { throw new \Exception("Failed to get DNS record for {$this->namespace}"); } // It may happen that result contains other domains depending on the host DNS setup // that's why in_array() and not just !empty() if (in_array($this->namespace, array_column($records, 'host'))) { $this->status |= Domain::STATUS_VERIFIED; $this->save(); return true; } return false; } /** * Returns the wallet by which the domain is controlled * * @return \App\Wallet A wallet object */ public function wallet(): ?Wallet { // Note: Not all domains have a entitlement/wallet - $entitlement = $this->entitlement()->first(); + $entitlement = $this->entitlement()->withTrashed()->first(); return $entitlement ? $entitlement->wallet : null; } } diff --git a/src/app/Http/Controllers/API/V4/Admin/UsersController.php b/src/app/Http/Controllers/API/V4/Admin/UsersController.php index 7175ae1f..b95409c2 100644 --- a/src/app/Http/Controllers/API/V4/Admin/UsersController.php +++ b/src/app/Http/Controllers/API/V4/Admin/UsersController.php @@ -1,199 +1,201 @@ input('search')); $owner = trim(request()->input('owner')); $result = collect([]); if ($owner) { if ($owner = User::find($owner)) { $result = $owner->users(false)->orderBy('email')->get(); } } elseif (strpos($search, '@')) { // Search by email - $user = User::where('email', $search)->first(); - if ($user) { - $result->push($user); - } else { + $result = User::withTrashed()->where('email', $search) + ->orderBy('email')->get(); + + if ($result->isEmpty()) { // Search by an alias $user_ids = UserAlias::where('alias', $search)->get()->pluck('user_id'); - if ($user_ids->isEmpty()) { - // Search by an external email - $user_ids = UserSetting::where('key', 'external_email') - ->where('value', $search)->get()->pluck('user_id'); - } + + // Search by an external email + $ext_user_ids = UserSetting::where('key', 'external_email') + ->where('value', $search)->get()->pluck('user_id'); + + $user_ids = $user_ids->merge($ext_user_ids)->unique(); if (!$user_ids->isEmpty()) { - $result = User::whereIn('id', $user_ids)->orderBy('email')->get(); + $result = User::withTrashed()->whereIn('id', $user_ids) + ->orderBy('email')->get(); } } } elseif (is_numeric($search)) { // Search by user ID - if ($user = User::find($search)) { + if ($user = User::withTrashed()->find($search)) { $result->push($user); } } elseif (!empty($search)) { // Search by domain - if ($domain = Domain::where('namespace', $search)->first()) { + if ($domain = Domain::withTrashed()->where('namespace', $search)->first()) { if ($wallet = $domain->wallet()) { - $result->push($wallet->owner); + $result->push($wallet->owner()->withTrashed()->first()); } } } // Process the result $result = $result->map(function ($user) { $data = $user->toArray(); $data = array_merge($data, self::userStatuses($user)); return $data; }); $result = [ 'list' => $result, 'count' => count($result), 'message' => \trans('app.search-foundxusers', ['x' => count($result)]), ]; return response()->json($result); } /** * Reset 2-Factor Authentication for the user * * @param \Illuminate\Http\Request $request The API request. * @params string $id User identifier * * @return \Illuminate\Http\JsonResponse The response */ public function reset2FA(Request $request, $id) { $user = User::find($id); if (empty($user)) { return $this->errorResponse(404); } $sku = Sku::where('title', '2fa')->first(); // Note: we do select first, so the observer can delete // 2FA preferences from Roundcube database, so don't // be tempted to replace first() with delete() below $entitlement = $user->entitlements()->where('sku_id', $sku->id)->first(); $entitlement->delete(); return response()->json([ 'status' => 'success', 'message' => __('app.user-reset-2fa-success'), ]); } /** * Suspend the user * * @param \Illuminate\Http\Request $request The API request. * @params string $id User identifier * * @return \Illuminate\Http\JsonResponse The response */ public function suspend(Request $request, $id) { $user = User::find($id); if (empty($user)) { return $this->errorResponse(404); } $user->suspend(); return response()->json([ 'status' => 'success', 'message' => __('app.user-suspend-success'), ]); } /** * Un-Suspend the user * * @param \Illuminate\Http\Request $request The API request. * @params string $id User identifier * * @return \Illuminate\Http\JsonResponse The response */ public function unsuspend(Request $request, $id) { $user = User::find($id); if (empty($user)) { return $this->errorResponse(404); } $user->unsuspend(); return response()->json([ 'status' => 'success', 'message' => __('app.user-unsuspend-success'), ]); } /** * Update user data. * * @param \Illuminate\Http\Request $request The API request. * @params 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); } // For now admins can change only user external email address $rules = []; if (array_key_exists('external_email', $request->input())) { $rules['external_email'] = 'email'; } // Validate input $v = Validator::make($request->all(), $rules); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } // Update user settings $settings = $request->only(array_keys($rules)); if (!empty($settings)) { $user->setSettings($settings); } return response()->json([ 'status' => 'success', 'message' => __('app.user-update-success'), ]); } } diff --git a/src/resources/vue/Admin/Dashboard.vue b/src/resources/vue/Admin/Dashboard.vue index f1616a88..9d267bf8 100644 --- a/src/resources/vue/Admin/Dashboard.vue +++ b/src/resources/vue/Admin/Dashboard.vue @@ -1,83 +1,94 @@ diff --git a/src/tests/Browser.php b/src/tests/Browser.php index d2af9a9b..e319856f 100644 --- a/src/tests/Browser.php +++ b/src/tests/Browser.php @@ -1,228 +1,245 @@ elements($selector); $count = count($elements); if ($visible) { foreach ($elements as $element) { if (!$element->isDisplayed()) { $count--; } } } Assert::assertEquals($expected_count, $count, "Count of [$selector] elements is not $expected_count"); return $this; } /** * Assert Tip element content */ public function assertTip($selector, $content) { return $this->click($selector) ->withinBody(function ($browser) use ($content) { $browser->waitFor('div.tooltip .tooltip-inner') ->assertSeeIn('div.tooltip .tooltip-inner', $content); }) ->click($selector); } /** * Assert Toast element content (and close it) */ public function assertToast(string $type, string $message, $title = null) { return $this->withinBody(function ($browser) use ($type, $title, $message) { $browser->with(new Toast($type), function (Browser $browser) use ($title, $message) { $browser->assertToastTitle($title) ->assertToastMessage($message) ->closeToast(); }); }); } /** * Assert specified error page is displayed. */ public function assertErrorPage(int $error_code) { $this->with(new Error($error_code), function ($browser) { // empty, assertions will be made by the Error component itself }); return $this; } /** * Assert that the given element has specified class assigned. */ public function assertHasClass($selector, $class_name) { $element = $this->resolver->findOrFail($selector); $classes = explode(' ', (string) $element->getAttribute('class')); Assert::assertContains($class_name, $classes, "[$selector] has no class '{$class_name}'"); return $this; } /** * Assert that the given element is readonly */ public function assertReadonly($selector) { $element = $this->resolver->findOrFail($selector); $value = $element->getAttribute('readonly'); Assert::assertTrue($value == 'true', "Element [$selector] is not readonly"); return $this; } /** * Assert that the given element is not readonly */ public function assertNotReadonly($selector) { $element = $this->resolver->findOrFail($selector); $value = $element->getAttribute('readonly'); Assert::assertTrue($value != 'true', "Element [$selector] is not readonly"); return $this; } /** * Assert that the given element contains specified text, * no matter it's displayed or not. */ public function assertText($selector, $text) { $element = $this->resolver->findOrFail($selector); - Assert::assertTrue(strpos($element->getText(), $text) !== false, "No expected text in [$selector]"); + if ($text === '') { + Assert::assertTrue((string) $element->getText() === $text, "Element's text is not empty [$selector]"); + } else { + Assert::assertTrue(strpos($element->getText(), $text) !== false, "No expected text in [$selector]"); + } + + return $this; + } + + /** + * Assert that the given element contains specified text, + * no matter it's displayed or not - using a regular expression. + */ + public function assertTextRegExp($selector, $regexp) + { + $element = $this->resolver->findOrFail($selector); + + Assert::assertRegExp($regexp, $element->getText(), "No expected text in [$selector]"); return $this; } /** * Remove all toast messages */ public function clearToasts() { $this->script("jQuery('.toast-container > *').remove()"); return $this; } /** * Check if in Phone mode */ public static function isPhone() { return getenv('TESTS_MODE') == 'phone'; } /** * Check if in Tablet mode */ public static function isTablet() { return getenv('TESTS_MODE') == 'tablet'; } /** * Check if in Desktop mode */ public static function isDesktop() { return !self::isPhone() && !self::isTablet(); } /** * Returns content of a downloaded file */ public function readDownloadedFile($filename, $sleep = 5) { $filename = __DIR__ . "/Browser/downloads/$filename"; // Give the browser a chance to finish download if (!file_exists($filename) && $sleep) { sleep($sleep); } Assert::assertFileExists($filename); return file_get_contents($filename); } /** * Removes downloaded file */ public function removeDownloadedFile($filename) { @unlink(__DIR__ . "/Browser/downloads/$filename"); return $this; } /** * Clears the input field and related vue v-model data. */ public function vueClear($selector) { if ($this->resolver->prefix != 'body') { $selector = $this->resolver->prefix . ' ' . $selector; } // The existing clear(), and type() with empty string do not work. // We have to clear the field and dispatch 'input' event programatically. $this->script( "var element = document.querySelector('$selector');" . "element.value = '';" . "element.dispatchEvent(new Event('input'))" ); return $this; } /** * Execute code within body context. * Useful to execute code that selects elements outside of a component context */ public function withinBody($callback) { if ($this->resolver->prefix != 'body') { $orig_prefix = $this->resolver->prefix; $this->resolver->prefix = 'body'; } call_user_func($callback, $this); if (isset($orig_prefix)) { $this->resolver->prefix = $orig_prefix; } return $this; } } diff --git a/src/tests/Browser/Admin/DashboardTest.php b/src/tests/Browser/Admin/DashboardTest.php index c0d62dd6..a6077e53 100644 --- a/src/tests/Browser/Admin/DashboardTest.php +++ b/src/tests/Browser/Admin/DashboardTest.php @@ -1,79 +1,140 @@ getTestUser('jack@kolab.org'); $jack->setSetting('external_email', null); + + $this->deleteTestUser('test@testsearch.com'); + $this->deleteTestDomain('testsearch.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $jack = $this->getTestUser('jack@kolab.org'); $jack->setSetting('external_email', null); + $this->deleteTestUser('test@testsearch.com'); + $this->deleteTestDomain('testsearch.com'); + parent::tearDown(); } /** * Test user search */ public function testSearch(): void { $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('jeroen@jeroen.jeroen', 'jeroen', true) ->on(new Dashboard()) ->assertFocused('@search input') ->assertMissing('@search table'); // Test search with no results $browser->type('@search input', 'unknown') ->click('@search form button') ->assertToast(Toast::TYPE_INFO, '0 user accounts have been found.') ->assertMissing('@search table'); $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $jack->setSetting('external_email', 'john.doe.external@gmail.com'); // Test search with multiple results $browser->type('@search input', 'john.doe.external@gmail.com') ->click('@search form button') ->assertToast(Toast::TYPE_INFO, '2 user accounts have been found.') - ->whenAvailable('@search table', function (Browser $browser) { - $browser->assertElementsCount('tbody tr', 2); - // TODO: Assert table content + ->whenAvailable('@search table', function (Browser $browser) use ($john, $jack) { + $browser->assertElementsCount('tbody tr', 2) + ->with('tbody tr:first-child', function (Browser $browser) use ($jack) { + $browser->assertSeeIn('td:nth-child(1) a', $jack->email) + ->assertSeeIn('td:nth-child(2) a', $jack->id) + ->assertVisible('td:nth-child(3)') + ->assertTextRegExp('td:nth-child(3)', '/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/') + ->assertVisible('td:nth-child(4)') + ->assertText('td:nth-child(4)', ''); + }) + ->with('tbody tr:last-child', function (Browser $browser) use ($john) { + $browser->assertSeeIn('td:nth-child(1) a', $john->email) + ->assertSeeIn('td:nth-child(2) a', $john->id) + ->assertVisible('td:nth-child(3)') + ->assertTextRegExp('td:nth-child(3)', '/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/') + ->assertVisible('td:nth-child(4)') + ->assertText('td:nth-child(4)', ''); + }); }); // Test search with single record result -> redirect to user page $browser->type('@search input', 'kolab.org') ->click('@search form button') ->assertMissing('@search table') ->waitForLocation('/user/' . $john->id) ->waitUntilMissing('.app-loader') ->whenAvailable('#user-info', function (Browser $browser) use ($john) { $browser->assertSeeIn('.card-title', $john->email); }); }); } + + /** + * Test user search deleted user/domain + */ + public function testSearchDeleted(): void + { + $this->browse(function (Browser $browser) { + $browser->visit(new Home()) + ->submitLogon('jeroen@jeroen.jeroen', 'jeroen', true) + ->on(new Dashboard()) + ->assertFocused('@search input') + ->assertMissing('@search table'); + + // Deleted users/domains + $domain = $this->getTestDomain('testsearch.com', ['type' => \App\Domain::TYPE_EXTERNAL]); + $user = $this->getTestUser('test@testsearch.com'); + $plan = \App\Plan::where('title', 'group')->first(); + $user->assignPlan($plan, $domain); + $user->setAliases(['alias@testsearch.com']); + Queue::fake(); + $user->delete(); + + // Test search with multiple results + $browser->type('@search input', 'testsearch.com') + ->click('@search form button') + ->assertToast(Toast::TYPE_INFO, '1 user accounts have been found.') + ->whenAvailable('@search table', function (Browser $browser) use ($user) { + $browser->assertElementsCount('tbody tr', 1) + ->assertVisible('tbody tr:first-child.text-secondary') + ->with('tbody tr:first-child', function (Browser $browser) use ($user) { + $browser->assertSeeIn('td:nth-child(1) span', $user->email) + ->assertSeeIn('td:nth-child(2) span', $user->id) + ->assertVisible('td:nth-child(3)') + ->assertTextRegExp('td:nth-child(3)', '/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/') + ->assertVisible('td:nth-child(4)') + ->assertTextRegExp('td:nth-child(4)', '/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/'); + }); + }); + }); + } } diff --git a/src/tests/Feature/Controller/Admin/UsersTest.php b/src/tests/Feature/Controller/Admin/UsersTest.php index 1381d38a..9070c0a0 100644 --- a/src/tests/Feature/Controller/Admin/UsersTest.php +++ b/src/tests/Feature/Controller/Admin/UsersTest.php @@ -1,295 +1,341 @@ deleteTestUser('UsersControllerTest1@userscontroller.com'); + $this->deleteTestUser('test@testsearch.com'); + $this->deleteTestDomain('testsearch.com'); $jack = $this->getTestUser('jack@kolab.org'); $jack->setSetting('external_email', null); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); + $this->deleteTestUser('test@testsearch.com'); + $this->deleteTestDomain('testsearch.com'); $jack = $this->getTestUser('jack@kolab.org'); $jack->setSetting('external_email', null); parent::tearDown(); } /** * Test users searching (/api/v4/users) */ public function testIndex(): void { $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); // Non-admin user $response = $this->actingAs($user)->get("api/v4/users"); $response->assertStatus(403); // Search with no search criteria $response = $this->actingAs($admin)->get("api/v4/users"); $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/users?search=abcd1234efgh5678"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); // Search by domain $response = $this->actingAs($admin)->get("api/v4/users?search=kolab.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); // Search by user ID $response = $this->actingAs($admin)->get("api/v4/users?search={$user->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); // Search by email (primary) $response = $this->actingAs($admin)->get("api/v4/users?search=john@kolab.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); // Search by email (alias) $response = $this->actingAs($admin)->get("api/v4/users?search=john.doe@kolab.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); // Search by email (external), expect two users in a result $jack = $this->getTestUser('jack@kolab.org'); $jack->setSetting('external_email', 'john.doe.external@gmail.com'); $response = $this->actingAs($admin)->get("api/v4/users?search=john.doe.external@gmail.com"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(2, $json['count']); $this->assertCount(2, $json['list']); $emails = array_column($json['list'], 'email'); $this->assertContains($user->email, $emails); $this->assertContains($jack->email, $emails); // Search by owner $response = $this->actingAs($admin)->get("api/v4/users?owner={$user->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(4, $json['count']); $this->assertCount(4, $json['list']); // Search by owner (Ned is a controller on John's wallets, // here we expect only users assigned to Ned's wallet(s)) $ned = $this->getTestUser('ned@kolab.org'); $response = $this->actingAs($admin)->get("api/v4/users?owner={$ned->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertCount(0, $json['list']); + + // Deleted users/domains + $domain = $this->getTestDomain('testsearch.com', ['type' => \App\Domain::TYPE_EXTERNAL]); + $user = $this->getTestUser('test@testsearch.com'); + $plan = \App\Plan::where('title', 'group')->first(); + $user->assignPlan($plan, $domain); + $user->setAliases(['alias@testsearch.com']); + Queue::fake(); + $user->delete(); + + $response = $this->actingAs($admin)->get("api/v4/users?search=test@testsearch.com"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(1, $json['count']); + $this->assertCount(1, $json['list']); + $this->assertSame($user->id, $json['list'][0]['id']); + $this->assertSame($user->email, $json['list'][0]['email']); + $this->assertTrue($json['list'][0]['isDeleted']); + + $response = $this->actingAs($admin)->get("api/v4/users?search=alias@testsearch.com"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(1, $json['count']); + $this->assertCount(1, $json['list']); + $this->assertSame($user->id, $json['list'][0]['id']); + $this->assertSame($user->email, $json['list'][0]['email']); + $this->assertTrue($json['list'][0]['isDeleted']); + + $response = $this->actingAs($admin)->get("api/v4/users?search=testsearch.com"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(1, $json['count']); + $this->assertCount(1, $json['list']); + $this->assertSame($user->id, $json['list'][0]['id']); + $this->assertSame($user->email, $json['list'][0]['email']); + $this->assertTrue($json['list'][0]['isDeleted']); } /** * Test reseting 2FA (POST /api/v4/users//reset2FA) */ public function testReset2FA(): void { $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $sku2fa = Sku::firstOrCreate(['title' => '2fa']); $user->assignSku($sku2fa); SecondFactor::seed('userscontrollertest1@userscontroller.com'); // Test unauthorized access to admin API $response = $this->actingAs($user)->post("/api/v4/users/{$user->id}/reset2FA", []); $response->assertStatus(403); $entitlements = $user->fresh()->entitlements()->where('sku_id', $sku2fa->id)->get(); $this->assertCount(1, $entitlements); $sf = new SecondFactor($user); $this->assertCount(1, $sf->factors()); // Test reseting 2FA $response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/reset2FA", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("2-Factor authentication reset successfully.", $json['message']); $this->assertCount(2, $json); $entitlements = $user->fresh()->entitlements()->where('sku_id', $sku2fa->id)->get(); $this->assertCount(0, $entitlements); $sf = new SecondFactor($user); $this->assertCount(0, $sf->factors()); } /** * Test user suspending (POST /api/v4/users//suspend) */ public function testSuspend(): void { Queue::fake(); // disable jobs $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); // Test unauthorized access to admin API $response = $this->actingAs($user)->post("/api/v4/users/{$user->id}/suspend", []); $response->assertStatus(403); $this->assertFalse($user->isSuspended()); // Test suspending the user $response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/suspend", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("User suspended successfully.", $json['message']); $this->assertCount(2, $json); $this->assertTrue($user->fresh()->isSuspended()); } /** * Test user un-suspending (POST /api/v4/users//unsuspend) */ public function testUnsuspend(): void { Queue::fake(); // disable jobs $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); // Test unauthorized access to admin API $response = $this->actingAs($user)->post("/api/v4/users/{$user->id}/unsuspend", []); $response->assertStatus(403); $this->assertFalse($user->isSuspended()); $user->suspend(); $this->assertTrue($user->isSuspended()); // Test suspending the user $response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/unsuspend", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("User unsuspended successfully.", $json['message']); $this->assertCount(2, $json); $this->assertFalse($user->fresh()->isSuspended()); } /** * Test user update (PUT /api/v4/users/) */ public function testUpdate(): void { $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); // Test unauthorized access to admin API $response = $this->actingAs($user)->put("/api/v4/users/{$user->id}", []); $response->assertStatus(403); // Test updatig the user data (empty data) $response = $this->actingAs($admin)->put("/api/v4/users/{$user->id}", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("User data updated successfully.", $json['message']); $this->assertCount(2, $json); // Test error handling $post = ['external_email' => 'aaa']; $response = $this->actingAs($admin)->put("/api/v4/users/{$user->id}", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame("The external email must be a valid email address.", $json['errors']['external_email'][0]); $this->assertCount(2, $json); // Test real update $post = ['external_email' => 'modified@test.com']; $response = $this->actingAs($admin)->put("/api/v4/users/{$user->id}", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("User data updated successfully.", $json['message']); $this->assertCount(2, $json); $this->assertSame('modified@test.com', $user->getSetting('external_email')); } }