diff --git a/src/app/Http/Controllers/API/DomainsController.php b/src/app/Http/Controllers/API/DomainsController.php index 6fbda15a..7ffa894e 100644 --- a/src/app/Http/Controllers/API/DomainsController.php +++ b/src/app/Http/Controllers/API/DomainsController.php @@ -1,279 +1,280 @@ user(); $list = []; foreach ($user->domains() as $domain) { if (!$domain->isPublic()) { $data = $domain->toArray(); $data = array_merge($data, self::domainStatuses($domain)); $list[] = $data; } } return response()->json($list); } /** * Show the form for creating a new resource. * * @return \Illuminate\Http\JsonResponse */ public function create() { // } /** * Confirm ownership of the specified domain (via DNS check). * * @param int $id Domain identifier * * @return \Illuminate\Http\JsonResponse|void */ public function confirm($id) { $domain = Domain::findOrFail($id); // Only owner (or admin) has access to the domain if (!Auth::guard()->user()->canRead($domain)) { return $this->errorResponse(403); } if (!$domain->confirm()) { + // TODO: This should include an error message to display to the user return response()->json(['status' => 'error']); } return response()->json([ 'status' => 'success', 'statusInfo' => self::statusInfo($domain), 'message' => __('app.domain-verify-success'), ]); } /** * Remove the specified resource from storage. * * @param int $id * * @return \Illuminate\Http\JsonResponse */ public function destroy($id) { // } /** * Show the form for editing the specified resource. * * @param int $id * * @return \Illuminate\Http\Response */ public function edit($id) { // } /** * Store a newly created resource in storage. * * @param \Illuminate\Http\Request $request * * @return \Illuminate\Http\Response */ public function store(Request $request) { // } /** * Get the information about the specified domain. * * @param int $id Domain identifier * * @return \Illuminate\Http\JsonResponse|void */ public function show($id) { $domain = Domain::findOrFail($id); // Only owner (or admin) has access to the domain if (!Auth::guard()->user()->canRead($domain)) { return $this->errorResponse(403); } $response = $domain->toArray(); // Add hash information to the response $response['hash_text'] = $domain->hash(Domain::HASH_TEXT); $response['hash_cname'] = $domain->hash(Domain::HASH_CNAME); $response['hash_code'] = $domain->hash(Domain::HASH_CODE); // Add DNS/MX configuration for the domain $response['dns'] = self::getDNSConfig($domain); $response['config'] = self::getMXConfig($domain->namespace); // Status info $response['statusInfo'] = self::statusInfo($domain); $response = array_merge($response, self::domainStatuses($domain)); return response()->json($response); } /** * Update the specified resource in storage. * * @param \Illuminate\Http\Request $request * @param int $id * * @return \Illuminate\Http\Response */ public function update(Request $request, $id) { // } /** * Provide DNS MX information to configure specified domain for */ protected static function getMXConfig(string $namespace): array { $entries = []; // copy MX entries from an existing domain if ($master = \config('dns.copyfrom')) { // TODO: cache this lookup foreach ((array) dns_get_record($master, DNS_MX) as $entry) { $entries[] = sprintf( "@\t%s\t%s\tMX\t%d %s.", \config('dns.ttl', $entry['ttl']), $entry['class'], $entry['pri'], $entry['target'] ); } } elseif ($static = \config('dns.static')) { $entries[] = strtr($static, array('\n' => "\n", '%s' => $namespace)); } // display SPF settings if ($spf = \config('dns.spf')) { $entries[] = ';'; foreach (['TXT', 'SPF'] as $type) { $entries[] = sprintf( "@\t%s\tIN\t%s\t\"%s\"", \config('dns.ttl'), $type, $spf ); } } return $entries; } /** * Provide sample DNS config for domain confirmation */ protected static function getDNSConfig(Domain $domain): array { $serial = date('Ymd01'); $hash_txt = $domain->hash(Domain::HASH_TEXT); $hash_cname = $domain->hash(Domain::HASH_CNAME); $hash = $domain->hash(Domain::HASH_CODE); return [ "@ IN SOA ns1.dnsservice.com. hostmaster.{$domain->namespace}. (", " {$serial} 10800 3600 604800 86400 )", ";", "@ IN A ", "www IN A ", ";", "{$hash_cname}.{$domain->namespace}. IN CNAME {$hash}.{$domain->namespace}.", "@ 3600 TXT \"{$hash_txt}\"", ]; } /** * Prepare domain statuses for the UI * * @param \App\Domain $domain Domain object * * @return array Statuses array */ protected static function domainStatuses(Domain $domain): array { return [ 'isLdapReady' => $domain->isLdapReady(), 'isConfirmed' => $domain->isConfirmed(), 'isVerified' => $domain->isVerified(), 'isSuspended' => $domain->isSuspended(), 'isActive' => $domain->isActive(), 'isDeleted' => $domain->isDeleted() || $domain->trashed(), ]; } /** * Domain status (extended) information. * * @param \App\Domain $domain Domain object * * @return array Status information */ public static function statusInfo(Domain $domain): array { $process = []; // If that is not a public domain, add domain specific steps $steps = [ 'domain-new' => true, 'domain-ldap-ready' => $domain->isLdapReady(), 'domain-verified' => $domain->isVerified(), 'domain-confirmed' => $domain->isConfirmed(), ]; $count = count($steps); // Create a process check list foreach ($steps as $step_name => $state) { $step = [ 'label' => $step_name, 'title' => \trans("app.process-{$step_name}"), 'state' => $state, ]; if ($step_name == 'domain-confirmed' && !$state) { $step['link'] = "/domain/{$domain->id}"; } $process[] = $step; if ($state) { $count--; } } return [ 'process' => $process, 'isReady' => $count === 0, ]; } } diff --git a/src/resources/vue/Domain/Info.vue b/src/resources/vue/Domain/Info.vue index 9b9cc539..d22b44ff 100644 --- a/src/resources/vue/Domain/Info.vue +++ b/src/resources/vue/Domain/Info.vue @@ -1,118 +1,117 @@ diff --git a/src/resources/vue/User/List.vue b/src/resources/vue/User/List.vue index 16ce9a32..f52191d4 100644 --- a/src/resources/vue/User/List.vue +++ b/src/resources/vue/User/List.vue @@ -1,124 +1,124 @@ diff --git a/src/tests/Browser.php b/src/tests/Browser.php index f5262de2..d45bec0a 100644 --- a/src/tests/Browser.php +++ b/src/tests/Browser.php @@ -1,152 +1,165 @@ elements($selector); $count = count($elements); if ($visible) { foreach ($elements as $element) { if (!$element->isDisplayed()) { $count--; } } } - Assert::assertEquals($expected_count, $count); + Assert::assertEquals($expected_count, $count, "Count of [$selector] elements is not $count"); return $this; } /** * Assert Tip element content */ public function assertTip($selector, $content) { return $this->click($selector) ->withinBody(function ($browser) use ($content) { $browser->assertSeeIn('div.tooltip .tooltip-inner', $content); }) ->click($selector); } /** * 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); + Assert::assertContains($class_name, $classes, "[$selector] has no class '{$class_name}'"); + + 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]"); 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) { $filename = __DIR__ . "/Browser/downloads/$filename"; // Give the browser a chance to finish download if (!file_exists($filename)) { sleep(2); } Assert::assertFileExists($filename); return file_get_contents($filename); } /** * Removes downloaded file */ public function removeDownloadedFile($filename) { @unlink(__DIR__ . "/Browser/downloads/$filename"); 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/DomainTest.php b/src/tests/Browser/DomainTest.php index 86f01a18..11585d02 100644 --- a/src/tests/Browser/DomainTest.php +++ b/src/tests/Browser/DomainTest.php @@ -1,134 +1,135 @@ browse(function ($browser) { $browser->visit('/domain/123')->on(new Home()); }); } /** * Test domain info page (non-existing domain id) */ public function testDomainInfo404(): void { $this->browse(function ($browser) { // FIXME: I couldn't make loginAs() method working // Note: Here we're also testing that unauthenticated request // is passed to logon form and then "redirected" to the requested page $browser->visit('/domain/123') ->on(new Home()) ->submitLogon('john@kolab.org', 'simple123') ->assertErrorPage(404); }); } /** * Test domain info page (existing domain) * * @depends testDomainInfo404 */ public function testDomainInfo(): void { $this->browse(function ($browser) { // Unconfirmed domain $domain = Domain::where('namespace', 'kolab.org')->first(); $domain->status ^= Domain::STATUS_CONFIRMED; $domain->save(); $browser->visit('/domain/' . $domain->id) ->on(new DomainInfo()) - // TODO: Test domain status box + ->assertVisible('@status') ->whenAvailable('@verify', function ($browser) use ($domain) { // Make sure the domain is confirmed now // TODO: Test verification process failure $domain->status |= Domain::STATUS_CONFIRMED; $domain->save(); $browser->assertSeeIn('pre', $domain->namespace) ->assertSeeIn('pre', $domain->hash()) ->click('button'); }) ->whenAvailable('@config', function ($browser) use ($domain) { $browser->assertSeeIn('pre', $domain->namespace); }) ->assertMissing('@verify') ->with(new Toast(Toast::TYPE_SUCCESS), function ($browser) { $browser->assertToastTitle('') ->assertToastMessage('Domain verified successfully') ->closeToast(); }); // Check that confirmed domain page contains only the config box $browser->visit('/domain/' . $domain->id) ->on(new DomainInfo()) ->assertMissing('@verify') ->assertPresent('@config'); }); } /** * Test domains list page (unauthenticated) */ public function testDomainListUnauth(): void { // Test that the page requires authentication $this->browse(function ($browser) { $browser->visit('/logout') ->visit('/domains') ->on(new Home()); }); } /** * Test domains list page * * @depends testDomainListUnauth */ public function testDomainList(): void { $this->browse(function ($browser) { // Login the user $browser->visit('/login') ->on(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) // On dashboard click the "Domains" link ->on(new Dashboard()) ->assertSeeIn('@links a.link-domains', 'Domains') ->click('@links a.link-domains') // On Domains List page click the domain entry ->on(new DomainList()) - // TODO: Assert domain status icon + ->assertVisible('@table tbody tr:first-child td:first-child svg.fa-globe.text-success') + ->assertText('@table tbody tr:first-child td:first-child svg title', 'Active') ->assertSeeIn('@table tbody tr:first-child td:first-child', 'kolab.org') ->click('@table tbody tr:first-child td:first-child a') // On Domain Info page verify that's the clicked domain ->on(new DomainInfo()) ->whenAvailable('@config', function ($browser) { $browser->assertSeeIn('pre', 'kolab.org'); }); }); // TODO: Test domains list acting as Ned (John's "delegatee") } } diff --git a/src/tests/Browser/LogonTest.php b/src/tests/Browser/LogonTest.php index 35d30b74..3fb95140 100644 --- a/src/tests/Browser/LogonTest.php +++ b/src/tests/Browser/LogonTest.php @@ -1,158 +1,159 @@ browse(function (Browser $browser) { $browser->visit(new Home()); $browser->within(new Menu(), function ($browser) { $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'webmail']); }); }); } /** * Test redirect to /login if user is unauthenticated */ public function testLogonRedirect(): void { $this->browse(function (Browser $browser) { $browser->visit('/dashboard'); // Checks if we're really on the login page $browser->waitForLocation('/login') ->on(new Home()); }); } /** * Logon with wrong password/user test */ public function testLogonWrongCredentials(): void { $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('john@kolab.org', 'wrong'); // Error message $browser->with(new Toast(Toast::TYPE_ERROR), function (Browser $browser) { $browser->assertToastTitle('Error') ->assertToastMessage('Invalid username or password.') ->closeToast(); }); // Checks if we're still on the logon page $browser->on(new Home()); }); } /** * Successful logon test */ public function testLogonSuccessful(): void { $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('john@kolab.org', 'simple123', true); // Checks if we're really on Dashboard page $browser->on(new Dashboard()) ->within(new Menu(), function ($browser) { $browser->assertMenuItems(['support', 'contact', 'webmail', 'logout']); }) ->assertUser('john@kolab.org'); - // TODO: Verify dashboard content + // Assert no "Account status" for this account + $browser->assertMissing('@status'); // Goto /domains and assert that the link on logo element // leads to the dashboard $browser->visit('/domains') ->waitForText('Domains') ->click('a.navbar-brand') ->on(new Dashboard()); // Test that visiting '/' with logged in user does not open logon form // but "redirects" to the dashboard $browser->visit('/')->on(new Dashboard()); }); } /** * Logout test * * @depends testLogonSuccessful */ public function testLogout(): void { $this->browse(function (Browser $browser) { $browser->on(new Dashboard()); // Click the Logout button $browser->within(new Menu(), function ($browser) { $browser->click('.link-logout'); }); // We expect the logon page $browser->waitForLocation('/login') ->on(new Home()); // with default menu $browser->within(new Menu(), function ($browser) { $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'webmail']); }); // Success toast message $browser->with(new Toast(Toast::TYPE_SUCCESS), function (Browser $browser) { $browser->assertToastTitle('') ->assertToastMessage('Successfully logged out') ->closeToast(); }); }); } /** * Logout by URL test */ public function testLogoutByURL(): void { $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('john@kolab.org', 'simple123', true); // Checks if we're really on Dashboard page $browser->on(new Dashboard()); // Use /logout url, and expect the logon page $browser->visit('/logout') ->waitForLocation('/login') ->on(new Home()); // with default menu $browser->within(new Menu(), function ($browser) { $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'webmail']); }); // Success toast message $browser->with(new Toast(Toast::TYPE_SUCCESS), function (Browser $browser) { $browser->assertToastTitle('') ->assertToastMessage('Successfully logged out') ->closeToast(); }); }); } } diff --git a/src/tests/Browser/Pages/Dashboard.php b/src/tests/Browser/Pages/Dashboard.php index feb95dc3..4fd18e93 100644 --- a/src/tests/Browser/Pages/Dashboard.php +++ b/src/tests/Browser/Pages/Dashboard.php @@ -1,56 +1,57 @@ assertPathIs('/dashboard') ->waitUntilMissing('@app .app-loader') ->assertVisible('@links'); } /** * Assert logged-in user * * @param \Laravel\Dusk\Browser $browser The browser object * @param string $user User email */ public function assertUser($browser, $user) { $browser->assertVue('$store.state.authInfo.email', $user, '@dashboard-component'); } /** * Get the element shortcuts for the page. * * @return array */ public function elements() { return [ '@app' => '#app', '@links' => '#dashboard-nav', + '@status' => '#status-box', ]; } } diff --git a/src/tests/Browser/StatusTest.php b/src/tests/Browser/StatusTest.php new file mode 100644 index 00000000..a2c51e33 --- /dev/null +++ b/src/tests/Browser/StatusTest.php @@ -0,0 +1,167 @@ +first(); + $domain->status ^= Domain::STATUS_CONFIRMED; + $domain->save(); + + $this->browse(function ($browser) use ($domain) { + $browser->visit(new Home()) + ->submitLogon('john@kolab.org', 'simple123', true) + ->on(new Dashboard()) + ->whenAvailable('@status', function ($browser) { + $browser->assertSeeIn('.card-title', 'Account status:') + ->assertSeeIn('.card-title span.text-danger', 'Not ready') + ->with('ul.status-list', function ($browser) { + $browser->assertElementsCount('li', 7) + ->assertVisible('li:nth-child(1) svg.fa-check-square') + ->assertSeeIn('li:nth-child(1) span', 'User registered') + ->assertVisible('li:nth-child(2) svg.fa-check-square') + ->assertSeeIn('li:nth-child(2) span', 'User created') + ->assertVisible('li:nth-child(3) svg.fa-check-square') + ->assertSeeIn('li:nth-child(3) span', 'User mailbox created') + ->assertVisible('li:nth-child(4) svg.fa-check-square') + ->assertSeeIn('li:nth-child(4) span', 'Custom domain registered') + ->assertVisible('li:nth-child(5) svg.fa-check-square') + ->assertSeeIn('li:nth-child(5) span', 'Custom domain created') + ->assertVisible('li:nth-child(6) svg.fa-check-square') + ->assertSeeIn('li:nth-child(6) span', 'Custom domain verified') + ->assertVisible('li:nth-child(7) svg.fa-square') + ->assertSeeIn('li:nth-child(7) a', 'Custom domain ownership verified'); + }); + }); + + // Confirm the domain and wait until the whole status box disappears + $domain->status |= Domain::STATUS_CONFIRMED; + $domain->save(); + + // At the moment, this may take about 10 seconds + $browser->waitUntilMissing('@status', 15); + }); + } + + /** + * Test domain status on domains list and domain info page + * + * @depends testDashboard + */ + public function testDomainStatus(): void + { + $domain = Domain::where('namespace', 'kolab.org')->first(); + $domain->status ^= Domain::STATUS_CONFIRMED; + $domain->save(); + + $this->browse(function ($browser) use ($domain) { + $browser->on(new Dashboard()) + ->click('@links a.link-domains') + ->on(new DomainList()) + // Assert domain status icon + ->assertVisible('@table tbody tr:first-child td:first-child svg.fa-globe.text-danger') + ->assertText('@table tbody tr:first-child td:first-child svg title', 'Not Ready') + ->click('@table tbody tr:first-child td:first-child a') + ->on(new DomainInfo()) + ->whenAvailable('@status', function ($browser) { + $browser->assertSeeIn('.card-title', 'Domain status:') + ->assertSeeIn('.card-title span.text-danger', 'Not ready') + ->with('ul.status-list', function ($browser) { + $browser->assertElementsCount('li', 4) + ->assertVisible('li:nth-child(1) svg.fa-check-square') + ->assertSeeIn('li:nth-child(1) span', 'Custom domain registered') + ->assertVisible('li:nth-child(2) svg.fa-check-square') + ->assertSeeIn('li:nth-child(2) span', 'Custom domain created') + ->assertVisible('li:nth-child(3) svg.fa-check-square') + ->assertSeeIn('li:nth-child(3) span', 'Custom domain verified') + ->assertVisible('li:nth-child(4) svg.fa-square') + ->assertSeeIn('li:nth-child(4) span', 'Custom domain ownership verified'); + }); + }); + + // Confirm the domain and wait until the whole status box disappears + $domain->status |= Domain::STATUS_CONFIRMED; + $domain->save(); + + // At the moment, this may take about 10 seconds + $browser->waitUntilMissing('@status', 15); + }); + } + + /** + * Test user status on users list + * + * @depends testDashboard + */ + public function testUserStatus(): void + { + $john = $this->getTestUser('john@kolab.org'); + $john->status ^= User::STATUS_IMAP_READY; + $john->save(); + + $this->browse(function ($browser) { + $browser->visit(new Dashboard()) + ->click('@links a.link-users') + ->on(new UserList()) + // Assert user status icons + ->assertVisible('@table tbody tr:first-child td:first-child svg.fa-user.text-success') + ->assertText('@table tbody tr:first-child td:first-child svg title', 'Active') + ->assertVisible('@table tbody tr:nth-child(2) td:first-child svg.fa-user.text-danger') + ->assertText('@table tbody tr:nth-child(2) td:first-child svg title', 'Not Ready') + ->click('@table tbody tr:nth-child(2) td:first-child a') + ->on(new UserInfo()) + ->with('@form', function (Browser $browser) { + // Assert stet in the user edit form + $browser->assertSeeIn('div.row:nth-child(1) label', 'Status') + ->assertSeeIn('div.row:nth-child(1) #status', 'Not Ready'); + }); + + // TODO: The status should also be live-updated here + // Maybe when we have proper websocket communication + }); + } +}