diff --git a/src/app/Http/Controllers/API/DomainsController.php b/src/app/Http/Controllers/API/DomainsController.php index ecaad33b..5313c9ff 100644 --- a/src/app/Http/Controllers/API/DomainsController.php +++ b/src/app/Http/Controllers/API/DomainsController.php @@ -1,216 +1,225 @@ user(); + $list = []; + + foreach ($user->domains() as $domain) { + if (!$domain->isPublic()) { + $list[] = $domain->toArray(); + } + } + + return response()->json($list); } /** * Show the form for creating a new resource. * * @return \Illuminate\Http\Response */ public function create() { // } /** * Confirm ownership of the specified domain (via DNS check). * * @param int $id Domain identifier * * @return \Illuminate\Http\Response */ public function confirm($id) { $domain = Domain::findOrFail($id); // Only owner (or admin) has access to the domain if (!self::hasAccess($domain)) { return abort(403); } if (!$domain->confirm()) { return response()->json(['status' => 'error']); } return response()->json([ 'status' => 'success', 'message' => __('app.domain-verify-success'), ]); } /** * Remove the specified resource from storage. * * @param int $id * * @return \Illuminate\Http\Response */ 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\Response */ public function show($id) { $domain = Domain::findOrFail($id); // Only owner (or admin) has access to the domain if (!self::hasAccess($domain)) { return abort(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); $response['confirmed'] = $domain->isConfirmed(); 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}\"", ]; } /** * Check if the current user has access to the domain * * @param \App\Domain Domain * * @return bool True if current user has access, False otherwise */ protected static function hasAccess(Domain $domain): bool { $user = Auth::guard()->user(); $entitlement = $domain->entitlement()->first(); // TODO: Admins return $entitlement && $entitlement->owner_id == $user->id; } } diff --git a/src/composer.json b/src/composer.json index b879bbd3..e14804fa 100644 --- a/src/composer.json +++ b/src/composer.json @@ -1,81 +1,81 @@ { "name": "laravel/laravel", "type": "project", "description": "The Laravel Framework.", "keywords": [ "framework", "laravel" ], "license": "MIT", "repositories": [ { "type": "vcs", "url": "https://git.kolab.org/diffusion/PNL/php-net_ldap3.git" } ], "require": { "php": "^7.1.3", "doctrine/dbal": "^2.9", "fideloper/proxy": "^4.0", "geoip2/geoip2": "^2.9", "iatstuti/laravel-nullable-fields": "^1.0", "kolab/net_ldap3": "dev-master", "laravel/framework": "5.8.*", "laravel/tinker": "^1.0", "silviolleite/laravelpwa": "^1.0", "spatie/laravel-translatable": "^4.2", "swooletw/laravel-swoole": "^2.6", "torann/currency": "^1.0", "torann/geoip": "^1.0", "tymon/jwt-auth": "^1.0" }, "require-dev": { "beyondcode/laravel-dump-server": "^1.0", "beyondcode/laravel-er-diagram-generator": "^1.3", "filp/whoops": "^2.0", "fzaninotto/faker": "^1.4", - "laravel/dusk": "^5.5", + "laravel/dusk": "~5.9.1", "mockery/mockery": "^1.0", "nunomaduro/collision": "^3.0", "phpunit/phpunit": "^7.5" }, "config": { "optimize-autoloader": true, "preferred-install": "dist", "sort-packages": true }, "extra": { "laravel": { "dont-discover": [] } }, "autoload": { "psr-4": { "App\\": "app/" }, "classmap": [ "database/seeds", "database/factories", "include" ] }, "autoload-dev": { "psr-4": { "Tests\\": "tests/" } }, "minimum-stability": "dev", "prefer-stable": true, "scripts": { "post-autoload-dump": [ "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", "@php artisan package:discover --ansi" ], "post-root-package-install": [ "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"" ], "post-create-project-cmd": [ "@php artisan key:generate --ansi" ] } } diff --git a/src/resources/vue/components/Dashboard.vue b/src/resources/vue/components/Dashboard.vue index 1dfc77ef..45874ceb 100644 --- a/src/resources/vue/components/Dashboard.vue +++ b/src/resources/vue/components/Dashboard.vue @@ -1,85 +1,88 @@ diff --git a/src/resources/vue/components/Domain.vue b/src/resources/vue/components/Domain/Info.vue similarity index 98% rename from src/resources/vue/components/Domain.vue rename to src/resources/vue/components/Domain/Info.vue index 43a88b50..d59e2934 100644 --- a/src/resources/vue/components/Domain.vue +++ b/src/resources/vue/components/Domain/Info.vue @@ -1,83 +1,81 @@ diff --git a/src/resources/vue/components/Domain/List.vue b/src/resources/vue/components/Domain/List.vue new file mode 100644 index 00000000..bc3c1ea6 --- /dev/null +++ b/src/resources/vue/components/Domain/List.vue @@ -0,0 +1,44 @@ + + + diff --git a/src/resources/vue/js/routes.js b/src/resources/vue/js/routes.js index 40c36ec4..527f6a01 100644 --- a/src/resources/vue/js/routes.js +++ b/src/resources/vue/js/routes.js @@ -1,80 +1,87 @@ import Vue from 'vue' import VueRouter from 'vue-router' Vue.use(VueRouter) import DashboardComponent from '../components/Dashboard' -import DomainComponent from '../components/Domain' +import DomainInfoComponent from '../components/Domain/Info' +import DomainListComponent from '../components/Domain/List' import Error404Component from '../components/404' import LoginComponent from '../components/Login' import LogoutComponent from '../components/Logout' import PasswordResetComponent from '../components/PasswordReset' import SignupComponent from '../components/Signup' import store from './store' const routes = [ { path: '/', redirect: { name: 'login' } }, { path: '/dashboard', name: 'dashboard', component: DashboardComponent, meta: { requiresAuth: true } }, { path: '/domain/:domain', name: 'domain', - component: DomainComponent, + component: DomainInfoComponent, + meta: { requiresAuth: true } + }, + { + path: '/domains', + name: 'domains', + component: DomainListComponent, meta: { requiresAuth: true } }, { path: '/login', name: 'login', component: LoginComponent }, { path: '/logout', name: 'logout', component: LogoutComponent }, { path: '/password-reset/:code?', name: 'password-reset', component: PasswordResetComponent }, { path: '/signup/:param?', name: 'signup', component: SignupComponent }, { name: '404', path: '*', component: Error404Component } ] const router = new VueRouter({ mode: 'history', routes }) router.beforeEach((to, from, next) => { // check if the route requires authentication and user is not logged in if (to.matched.some(route => route.meta.requiresAuth) && !store.state.isLoggedIn) { // remember the original request, to use after login store.state.afterLogin = to; // redirect to login page next({ name: 'login' }) return } next() }) export default router diff --git a/src/tests/Browser/DomainTest.php b/src/tests/Browser/DomainTest.php index 90dc43bc..86cdd586 100644 --- a/src/tests/Browser/DomainTest.php +++ b/src/tests/Browser/DomainTest.php @@ -1,92 +1,135 @@ browse(function (Browser $browser) { $browser->visit('/domain/123')->on(new Home()); }); } /** * Test domain info page (non-existing domain id) */ public function testDomainInfo404(): void { $this->browse(function (Browser $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') // TODO: the check below could look simpler, but we can't // just remove the callback argument. We'll create - // Browser wrappen in future, then we could create expectError() method + // Browser wrapper in future, then we could create expectError() method ->with(new Error('404'), function (Browser $browser) { }); }); } /** * Test domain info page (existing domain) * * @depends testDomainInfo404 */ public function testDomainInfo(): void { $this->browse(function (Browser $browser) { // Unconfirmed domain $domain = Domain::where('namespace', 'kolab.org')->first(); $domain->status ^= Domain::STATUS_CONFIRMED; $domain->save(); $browser->visit('/domain/' . $domain->id) - ->on(new DomainPage()) + ->on(new DomainInfo()) ->whenAvailable('@verify', function (Browser $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 $browser) use ($domain) { $browser->assertSeeIn('pre', $domain->namespace); }) ->assertMissing('@verify') ->with(new Toast(Toast::TYPE_SUCCESS), function (Browser $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 DomainPage()) + ->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) { + $browser->visit('/logout') + ->visit('/domains') + ->on(new Home()); + }); + } + + /** + * Test domains list page + * + * @depends testDomainListUnauth + */ + public function testDomainList(): void + { + $this->browse(function (Browser $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()) + ->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) { + $browser->assertSeeIn('pre', 'kolab.org'); + }); + }); + } } diff --git a/src/tests/Browser/Pages/Dashboard.php b/src/tests/Browser/Pages/Dashboard.php index a9d28c94..73378823 100644 --- a/src/tests/Browser/Pages/Dashboard.php +++ b/src/tests/Browser/Pages/Dashboard.php @@ -1,43 +1,45 @@ assertPathIs('/dashboard'); - $browser->assertSee('Dashboard'); + $browser->assertPathIs('/dashboard') + ->waitUntilMissing('@app .app-loader') + ->assertSee('Dashboard'); } /** * Get the element shortcuts for the page. * * @return array */ public function elements() { return [ '@app' => '#app', + '@links' => '#dashboard-nav', ]; } } diff --git a/src/tests/Browser/Pages/Domain.php b/src/tests/Browser/Pages/DomainInfo.php similarity index 96% copy from src/tests/Browser/Pages/Domain.php copy to src/tests/Browser/Pages/DomainInfo.php index e372c5bd..9c56c2c4 100644 --- a/src/tests/Browser/Pages/Domain.php +++ b/src/tests/Browser/Pages/DomainInfo.php @@ -1,46 +1,46 @@ waitUntilMissing('@app .app-loader') ->assertPresent('@config,@verify'); } /** * Get the element shortcuts for the page. * * @return array */ public function elements(): array { return [ '@app' => '#app', '@config' => '#domain-config', '@verify' => '#domain-verify', ]; } } diff --git a/src/tests/Browser/Pages/Domain.php b/src/tests/Browser/Pages/DomainList.php similarity index 67% rename from src/tests/Browser/Pages/Domain.php rename to src/tests/Browser/Pages/DomainList.php index e372c5bd..2d403608 100644 --- a/src/tests/Browser/Pages/Domain.php +++ b/src/tests/Browser/Pages/DomainList.php @@ -1,46 +1,47 @@ waitUntilMissing('@app .app-loader') - ->assertPresent('@config,@verify'); + $browser->assertPathIs($this->url()) + ->waitUntilMissing('@app .app-loader') + ->assertSeeIn('@list .card-title', 'Domains List'); } /** * Get the element shortcuts for the page. * * @return array */ public function elements(): array { return [ '@app' => '#app', - '@config' => '#domain-config', - '@verify' => '#domain-verify', + '@list' => '#domain-list', + '@table' => '#domain-list table', ]; } } diff --git a/src/tests/Feature/Controller/DomainsTest.php b/src/tests/Feature/Controller/DomainsTest.php index 01e00655..87510729 100644 --- a/src/tests/Feature/Controller/DomainsTest.php +++ b/src/tests/Feature/Controller/DomainsTest.php @@ -1,113 +1,139 @@ delete(); Domain::where('namespace', 'domainscontroller.com')->delete(); } /** * Test domain confirm request */ public function testConfirm(): void { $sku_domain = Sku::where('title', 'domain')->first(); $user = $this->getTestUser('test1@domainscontroller.com'); $domain = $this->getTestDomain('domainscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); // No entitlement (user has no access to this domain), expect 403 $response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}/confirm"); $response->assertStatus(403); Entitlement::create([ 'owner_id' => $user->id, 'wallet_id' => $user->wallets()->first()->id, 'sku_id' => $sku_domain->id, 'entitleable_id' => $domain->id, 'entitleable_type' => Domain::class ]); $response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}/confirm"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals('error', $json['status']); $domain->status |= Domain::STATUS_CONFIRMED; $domain->save(); $response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}/confirm"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals('success', $json['status']); $this->assertEquals('Domain verified successfully', $json['message']); } + /** + * Test fetching domains list + */ + public function testIndex(): void + { + // User with no domains + $user = $this->getTestUser('test1@domainscontroller.com'); + $response = $this->actingAs($user)->get("api/v4/domains"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame([], $json); + + // User with custom domain(s) + $user = $this->getTestUser('john@kolab.org'); + + $response = $this->actingAs($user)->get("api/v4/domains"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertCount(1, $json); + $this->assertSame('kolab.org', $json[0]['namespace']); + } + /** * Test fetching domain info */ public function testShow(): void { $sku_domain = Sku::where('title', 'domain')->first(); $user = $this->getTestUser('test1@domainscontroller.com'); $domain = $this->getTestDomain('domainscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); // No entitlement (user has no access to this domain), expect 403 $response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}"); $response->assertStatus(403); Entitlement::create([ 'owner_id' => $user->id, 'wallet_id' => $user->wallets()->first()->id, 'sku_id' => $sku_domain->id, 'entitleable_id' => $domain->id, 'entitleable_type' => Domain::class ]); $response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals($domain->id, $json['id']); $this->assertEquals($domain->namespace, $json['namespace']); $this->assertEquals($domain->status, $json['status']); $this->assertEquals($domain->type, $json['type']); $this->assertTrue($json['confirmed'] === false); $this->assertSame($domain->hash(Domain::HASH_TEXT), $json['hash_text']); $this->assertSame($domain->hash(Domain::HASH_CNAME), $json['hash_cname']); $this->assertSame($domain->hash(Domain::HASH_CODE), $json['hash_code']); $this->assertCount(4, $json['config']); $this->assertTrue(strpos(implode("\n", $json['config']), $domain->namespace) !== false); $this->assertCount(8, $json['dns']); $this->assertTrue(strpos(implode("\n", $json['dns']), $domain->namespace) !== false); $this->assertTrue(strpos(implode("\n", $json['dns']), $domain->hash()) !== false); } }