diff --git a/src/app/Http/Controllers/API/UsersController.php b/src/app/Http/Controllers/API/UsersController.php index 09cdbdbd..fff51ec8 100644 --- a/src/app/Http/Controllers/API/UsersController.php +++ b/src/app/Http/Controllers/API/UsersController.php @@ -1,247 +1,250 @@ middleware('auth:api', ['except' => ['login']]); } /** * Helper method for other controllers with user auto-logon * functionality * * @param \App\User $user User model object */ public static function logonResponse(User $user) { $token = auth()->login($user); return response()->json([ 'status' => 'success', 'access_token' => $token, 'token_type' => 'bearer', 'expires_in' => Auth::guard()->factory()->getTTL() * 60, ]); } /** * Display a listing of the resources. * * The user themself, and other user entitlements. * * @return \Illuminate\Http\Response */ public function index() { $user = Auth::user(); if (!$user) { return response()->json(['error' => 'unauthorized'], 401); } $result = [$user]; $user->entitlements()->each( function ($entitlement) { $result[] = User::find($entitlement->user_id); } ); return response()->json($result); } /** * Get the authenticated User * * @return \Illuminate\Http\JsonResponse */ public function info() { $user = $this->guard()->user(); $response = $user->toArray(); $response['statusInfo'] = self::statusInfo($user); return response()->json($response); } /** * Get a JWT token via given credentials. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse */ public function login(Request $request) { $credentials = $request->only('email', 'password'); if ($token = $this->guard()->attempt($credentials)) { return $this->respondWithToken($token); } return response()->json(['error' => 'Unauthorized'], 401); } /** * Log the user out (Invalidate the token) * * @return \Illuminate\Http\JsonResponse */ public function logout() { $this->guard()->logout(); - return response()->json(['message' => 'Successfully logged out']); + return response()->json([ + 'status' => 'success', + 'message' => __('auth.logoutsuccess') + ]); } /** * Refresh a token. * * @return \Illuminate\Http\JsonResponse */ public function refresh() { return $this->respondWithToken($this->guard()->refresh()); } /** * Get the token array structure. * * @param string $token Respond with this token. * * @return \Illuminate\Http\JsonResponse */ protected function respondWithToken($token) { return response()->json( [ 'access_token' => $token, 'token_type' => 'bearer', 'expires_in' => $this->guard()->factory()->getTTL() * 60 ] ); } /** * Display the specified resource. * * @param int $id The account to show information for. * * @return \Illuminate\Http\Response */ public function show($id) { $user = Auth::user(); if (!$user) { return abort(403); } $result = false; $user->entitlements()->each( function ($entitlement) { if ($entitlement->user_id == $id) { $result = true; } } ); if ($user->id == $id) { $result = true; } if (!$result) { return abort(404); } return \App\User::find($id); } /** * User status (extended) information * * @param \App\User $user User object * * @return array Status information */ public static function statusInfo(User $user): array { $status = 'new'; $process = []; $steps = [ 'user-new' => true, 'user-ldap-ready' => 'isLdapReady', 'user-imap-ready' => 'isImapReady', ]; if ($user->isDeleted()) { $status = 'deleted'; } elseif ($user->isSuspended()) { $status = 'suspended'; } elseif ($user->isActive()) { $status = 'active'; } list ($local, $domain) = explode('@', $user->email); $domain = Domain::where('namespace', $domain)->first(); // If that is not a public domain, add domain specific steps if (!$domain->isPublic()) { $steps['domain-new'] = true; $steps['domain-ldap-ready'] = 'isLdapReady'; // $steps['domain-verified'] = 'isVerified'; $steps['domain-confirmed'] = 'isConfirmed'; } // Create a process check list foreach ($steps as $step_name => $func) { $object = strpos($step_name, 'user-') === 0 ? $user : $domain; $step = [ 'label' => $step_name, 'title' => __("app.process-{$step_name}"), 'state' => is_bool($func) ? $func : $object->{$func}(), ]; if ($step_name == 'domain-confirmed' && !$step['state']) { $step['link'] = "/domain/{$domain->id}"; } $process[] = $step; } return [ 'process' => $process, 'status' => $status, ]; } /** * Get the guard to be used during authentication. * * @return \Illuminate\Contracts\Auth\Guard */ public function guard() { return Auth::guard(); } } diff --git a/src/app/Http/Middleware/RequestLogger.php b/src/app/Http/Middleware/RequestLogger.php index 45f61f32..aa3e9ccd 100644 --- a/src/app/Http/Middleware/RequestLogger.php +++ b/src/app/Http/Middleware/RequestLogger.php @@ -1,24 +1,25 @@ fullUrl(); $method = $request->getMethod(); \Log::debug("C: $method $url -> " . var_export($request->bearerToken(), true)); - \Log::debug("S: " . var_export($response->getContent(), true)); + // On error response this is so noisy that makes the log unusable + // \Log::debug("S: " . var_export($response->getContent(), true)); } } } diff --git a/src/resources/js/app.js b/src/resources/js/app.js index 9c005ae3..45878e5f 100644 --- a/src/resources/js/app.js +++ b/src/resources/js/app.js @@ -1,139 +1,139 @@ /** * First we will load all of this project's JavaScript dependencies which * includes Vue and other libraries. It is a great starting point when * building robust, powerful web applications using Vue and Laravel. */ require('./bootstrap') window.Vue = require('vue') import AppComponent from '../vue/components/App' import MenuComponent from '../vue/components/Menu' import router from '../vue/js/routes.js' import store from '../vue/js/store' import VueToastr from '@deveodk/vue-toastr' // Add a response interceptor for general/validation error handler // This have to be before Vue and Router setup. Otherwise we would // not be able to handle axios responses initiated from inside // components created/mounted handlers (e.g. signup code verification link) window.axios.interceptors.response.use( response => { // Do nothing return response }, error => { var error_msg if (error.response && error.response.status == 422) { error_msg = "Form validation error" $.each(error.response.data.errors || {}, (idx, msg) => { $('form').each((i, form) => { const input_name = ($(form).data('validation-prefix') || '') + idx const input = $('#' + input_name) if (input.length) { input.addClass('is-invalid') .parent().append($('
') .text($.type(msg) === 'string' ? msg : msg.join('
'))) return false } }); }) $('form .is-invalid').first().focus() } else if (error.response && error.response.data) { error_msg = error.response.data.message } else { error_msg = error.request ? error.request.statusText : error.message } app.$toastr('error', error_msg || "Server Error", 'Error') // Pass the error as-is return Promise.reject(error) } ) const app = new Vue({ el: '#app', components: { 'app-component': AppComponent, 'menu-component': MenuComponent }, store, router, data() { return { isLoading: true } }, methods: { // Clear (bootstrap) form validation state clearFormValidation(form) { $(form).find('.is-invalid').removeClass('is-invalid') $(form).find('.invalid-feedback').remove() }, // Set user state to "logged in" loginUser(token, dashboard) { store.commit('loginUser') localStorage.setItem('token', token) axios.defaults.headers.common.Authorization = 'Bearer ' + token if (dashboard !== false) { router.push(store.state.afterLogin || { name: 'dashboard' }) } store.state.afterLogin = null }, // Set user state to "not logged in" logoutUser() { store.commit('logoutUser') localStorage.setItem('token', '') delete axios.defaults.headers.common.Authorization router.push({ name: 'login' }) }, // Display "loading" overlay (to be used by route components) startLoading() { this.isLoading = true // Lock the UI with the 'loading...' element $('#app').append($('
Loading
')) }, // Hide "loading" overlay stopLoading() { $('#app > .app-loader').fadeOut() this.isLoading = false }, errorPage(code, msg) { // Until https://github.com/vuejs/vue-router/issues/977 is implemented // we can't really use router to display error page as it has two side // effects: it changes the URL and adds the error page to browser history. // For now we'll be replacing current view with error page "manually". const map = { 400: "Bad request", 401: "Unauthorized", 403: "Access denied", 404: "Not found", 405: "Method not allowed", 500: "Internal server error" } if (!msg) msg = map[code] || "Unknown Error" const error_page = `
${code}
${msg}
` $('#app').children(':not(nav)').remove() $('#app').append(error_page) } } }) Vue.use(VueToastr, { defaultPosition: 'toast-bottom-right', - defaultTimeout: 50000 + defaultTimeout: 5000 }) diff --git a/src/resources/lang/en/auth.php b/src/resources/lang/en/auth.php index e5506df2..1581abd4 100644 --- a/src/resources/lang/en/auth.php +++ b/src/resources/lang/en/auth.php @@ -1,19 +1,19 @@ 'These credentials do not match our records.', 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', - + 'logoutsuccess' => 'Successfully logged out', ]; diff --git a/src/resources/vue/components/Login.vue b/src/resources/vue/components/Login.vue index 7b234403..339f9afa 100644 --- a/src/resources/vue/components/Login.vue +++ b/src/resources/vue/components/Login.vue @@ -1,83 +1,85 @@ diff --git a/src/resources/vue/components/Logout.vue b/src/resources/vue/components/Logout.vue index b69bd801..c1f3a8cc 100644 --- a/src/resources/vue/components/Logout.vue +++ b/src/resources/vue/components/Logout.vue @@ -1,9 +1,14 @@ diff --git a/src/resources/vue/components/Menu.vue b/src/resources/vue/components/Menu.vue index 81c85359..60cd40b8 100644 --- a/src/resources/vue/components/Menu.vue +++ b/src/resources/vue/components/Menu.vue @@ -1,56 +1,60 @@ diff --git a/src/tests/Browser/Components/Toast.php b/src/tests/Browser/Components/Toast.php new file mode 100644 index 00000000..e01b11d7 --- /dev/null +++ b/src/tests/Browser/Components/Toast.php @@ -0,0 +1,86 @@ +type = $type; + } + + /** + * Get the root selector for the component. + * + * @return string + */ + public function selector() + { + return '.toast-container > .toast.toast-' . $this->type; + } + + /** + * Assert that the browser page contains the component. + * + * @param Browser $browser + * + * @return void + */ + public function assert(Browser $browser) + { + $browser->waitFor($this->selector()); + } + + /** + * Get the element shortcuts for the component. + * + * @return array + */ + public function elements() + { + return [ + '@title' => ".toast-title", + '@message' => ".toast-message", + ]; + } + + /** + * Assert title of the toast element + */ + public function assertToastTitle(Browser $browser, string $title) + { + if (empty($title)) { + $browser->assertMissing('@title'); + } else { + $browser->assertSeeIn('@title', $title); + } + } + + /** + * Assert message of the toast element + */ + public function assertToastMessage(Browser $browser, string $message) + { + $browser->assertSeeIn('@message', $message); + } + + /** + * lose the toast with a click + */ + public function closeToast(Browser $browser) + { + $browser->click(); + } +} diff --git a/src/tests/Browser/LogonTest.php b/src/tests/Browser/LogonTest.php index da1ea234..af6bf0bd 100644 --- a/src/tests/Browser/LogonTest.php +++ b/src/tests/Browser/LogonTest.php @@ -1,113 +1,140 @@ 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 - * - * @return void */ - public function testLogonRedirect() + 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 - * - * @return void */ - public function testLogonWrongCredentials() + public function testLogonWrongCredentials(): void { $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('john@kolab.org', 'wrong'); // Checks if we're still on the logon page // FIXME: This assertion might be prone to timing issues // I guess we should wait until some error message appears $browser->on(new Home()); }); } /** * Successful logon test - * - * @return void */ - public function testLogonSuccessful() + 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()); $browser->within(new Menu(), function ($browser) { $browser->assertMenuItems(['support', 'contact', 'webmail', 'logout']); }); }); } /** * Logout test * * @depends testLogonSuccessful - * @return void */ - public function testLogout() + public function testLogout(): void { $this->browse(function (Browser $browser) { $browser->on(new Dashboard()); - // FIXME: Here we're testing click on Logout button - // We should also test if accessing /Logout url has the same effect + // Click the Logout button $browser->within(new Menu(), function ($browser) { $browser->click('.link-logout'); }); - // We expect the logoon page + // 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']); }); - // TODO: Test if the session is really destroyed + // 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/Feature/Controller/UsersTest.php b/src/tests/Feature/Controller/UsersTest.php index 0677b28a..fb04451e 100644 --- a/src/tests/Feature/Controller/UsersTest.php +++ b/src/tests/Feature/Controller/UsersTest.php @@ -1,145 +1,174 @@ delete(); Domain::where('namespace', 'userscontroller.com')->delete(); } /** * Test fetching current user info */ public function testInfo(): void { $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $domain = $this->getTestDomain('userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); $response = $this->actingAs($user)->get("api/auth/info"); $json = $response->json(); $response->assertStatus(200); $this->assertEquals($user->id, $json['id']); $this->assertEquals($user->email, $json['email']); $this->assertEquals(User::STATUS_NEW, $json['status']); $this->assertTrue(is_array($json['statusInfo'])); } public function testIndex(): void { $userA = $this->getTestUser('UserEntitlement2A@UserEntitlement.com'); $response = $this->actingAs($userA, 'api')->get("/api/v4/users/{$userA->id}"); $response->assertStatus(200); $response->assertJson(['id' => $userA->id]); $user = factory(User::class)->create(); $response = $this->actingAs($user)->get("/api/v4/users/{$userA->id}"); $response->assertStatus(404); } - public function testLogin(): void + public function testLogin(): string { - // TODO - $this->markTestIncomplete(); + $post = ['email' => 'john@kolab.org', 'password' => 'simple123']; + $response = $this->post("api/auth/login", $post); + $json = $response->json(); + + $response->assertStatus(200); + $this->assertTrue(!empty($json['access_token'])); + $this->assertEquals(\config('jwt.ttl') * 60, $json['expires_in']); + $this->assertEquals('bearer', $json['token_type']); + + return $json['access_token']; } - public function testLogout(): void + /** + * Test /api/auth/logout + * + * @depends testLogin + */ + public function testLogout($token): void { - // TODO - $this->markTestIncomplete(); + // Request with no token, testing that it requires auth + // TODO: This throws some errors and returns unexpected code 500 + // $response = $this->post("api/auth/logout"); + // $response->assertStatus(401); + + // Request with valid token + $response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])->post("api/auth/logout"); + + $json = $response->json(); + + $response->assertStatus(200); + $this->assertEquals('success', $json['status']); + $this->assertEquals('Successfully logged out', $json['message']); + + // Check if it really destroyed the token? + // TODO: This throws some errors and returns unexpected code 500 + // $response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])->get("api/auth/info"); + // $response->assertStatus(401); } public function testRefresh(): void { // TODO $this->markTestIncomplete(); } public function testStatusInfo(): void { $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $domain = $this->getTestDomain('userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); $user->status = User::STATUS_NEW; $user->save(); $result = UsersController::statusInfo($user); $this->assertSame('new', $result['status']); $this->assertCount(3, $result['process']); $this->assertSame('user-new', $result['process'][0]['label']); $this->assertSame(true, $result['process'][0]['state']); $this->assertSame('user-ldap-ready', $result['process'][1]['label']); $this->assertSame(false, $result['process'][1]['state']); $this->assertSame('user-imap-ready', $result['process'][2]['label']); $this->assertSame(false, $result['process'][2]['state']); $user->status |= User::STATUS_LDAP_READY | User::STATUS_IMAP_READY; $user->save(); $result = UsersController::statusInfo($user); $this->assertSame('new', $result['status']); $this->assertCount(3, $result['process']); $this->assertSame('user-new', $result['process'][0]['label']); $this->assertSame(true, $result['process'][0]['state']); $this->assertSame('user-ldap-ready', $result['process'][1]['label']); $this->assertSame(true, $result['process'][1]['state']); $this->assertSame('user-imap-ready', $result['process'][2]['label']); $this->assertSame(true, $result['process'][2]['state']); $user->status |= User::STATUS_ACTIVE; $user->save(); // $domain->status |= Domain::STATUS_VERIFIED; $domain->type = Domain::TYPE_EXTERNAL; $domain->save(); $result = UsersController::statusInfo($user); $this->assertSame('active', $result['status']); $this->assertCount(6, $result['process']); $this->assertSame('user-new', $result['process'][0]['label']); $this->assertSame(true, $result['process'][0]['state']); $this->assertSame('user-ldap-ready', $result['process'][1]['label']); $this->assertSame(true, $result['process'][1]['state']); $this->assertSame('user-imap-ready', $result['process'][2]['label']); $this->assertSame(true, $result['process'][2]['state']); $this->assertSame('domain-new', $result['process'][3]['label']); $this->assertSame(true, $result['process'][3]['state']); $this->assertSame('domain-ldap-ready', $result['process'][4]['label']); $this->assertSame(false, $result['process'][4]['state']); // $this->assertSame('domain-verified', $result['process'][5]['label']); // $this->assertSame(true, $result['process'][5]['state']); $this->assertSame('domain-confirmed', $result['process'][5]['label']); $this->assertSame(false, $result['process'][5]['state']); $user->status |= User::STATUS_DELETED; $user->save(); $result = UsersController::statusInfo($user); $this->assertSame('deleted', $result['status']); } }