diff --git a/src/resources/js/app.js b/src/resources/js/app.js index 65c7c6f5..226f6ff5 100644 --- a/src/resources/js/app.js +++ b/src/resources/js/app.js @@ -1,82 +1,85 @@ /** * 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 VueToastr from '@deveodk/vue-toastr' -const app = new Vue({ - el: '#app', - components: { - 'app-component': AppComponent, - 'menu-component': MenuComponent - }, - router, - methods: { - clearFormValidation: form => { - $(form).find('.is-invalid').removeClass('is-invalid') - $(form).find('.invalid-feedback').remove() - } - }, - mounted() { - this.$root.$on('clearFormValidation', (form) => { - this.clearFormValidation(form) - }) - } -}) - -Vue.use(VueToastr, { - defaultPosition: 'toast-bottom-right', - defaultTimeout: 50000 -}) - // 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 + }, + router, + methods: { + clearFormValidation: form => { + $(form).find('.is-invalid').removeClass('is-invalid') + $(form).find('.invalid-feedback').remove() + } + }, + mounted() { + this.$root.$on('clearFormValidation', (form) => { + this.clearFormValidation(form) + }) + } +}) + +Vue.use(VueToastr, { + defaultPosition: 'toast-bottom-right', + defaultTimeout: 50000 +}) diff --git a/src/resources/views/layouts/app.blade.php b/src/resources/views/layouts/app.blade.php index 23908f25..463a090d 100644 --- a/src/resources/views/layouts/app.blade.php +++ b/src/resources/views/layouts/app.blade.php @@ -1,22 +1,22 @@ {{ config('app.name') }} -- @yield('title') - @laravelPWA + {{-- TODO: PWA disabled for now: @laravelPWA --}} +
@yield('content')
- diff --git a/src/resources/vue/components/Signup.vue b/src/resources/vue/components/Signup.vue index 2575efb0..5be756f5 100644 --- a/src/resources/vue/components/Signup.vue +++ b/src/resources/vue/components/Signup.vue @@ -1,165 +1,184 @@ diff --git a/src/resources/vue/js/routes.js b/src/resources/vue/js/routes.js index c414645b..2e4373a0 100644 --- a/src/resources/vue/js/routes.js +++ b/src/resources/vue/js/routes.js @@ -1,69 +1,70 @@ import Vue from 'vue' import VueRouter from 'vue-router' Vue.use(VueRouter) import DashboardComponent from '../components/Dashboard' import Error404Component from '../components/404' import LoginComponent from '../components/Login' import LogoutComponent from '../components/Logout' 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: '/login', name: 'login', component: LoginComponent }, { path: '/logout', name: 'logout', component: LogoutComponent }, { path: '/signup/:code?', 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) { // redirect to login page next({ name: 'login' }) return } // if logged in redirect to dashboard if (to.path === '/login' && store.state.isLoggedIn) { next({ name: 'dashboard' }) return } next() }) export default router diff --git a/src/tests/Browser/ErrorTest.php b/src/tests/Browser/ErrorTest.php index f0e674b3..a92d4f4d 100644 --- a/src/tests/Browser/ErrorTest.php +++ b/src/tests/Browser/ErrorTest.php @@ -1,36 +1,38 @@ browse(function (Browser $browser) { $browser->visit('/unknown'); $browser->waitFor('#app > #error-page'); $browser->assertVisible('#app > #primary-menu'); $this->assertSame('404', $browser->text('#error-page .code')); $this->assertSame('Not Found', $browser->text('#error-page .message')); + }); + $this->browse(function (Browser $browser) { $browser->visit('/login/unknown'); $browser->waitFor('#app > #error-page'); $browser->assertVisible('#app > #primary-menu'); $this->assertSame('404', $browser->text('#error-page .code')); $this->assertSame('Not Found', $browser->text('#error-page .message')); }); // TODO: Test the same as above, but with use of Vue router } } diff --git a/src/tests/Browser/SignupTest.php b/src/tests/Browser/SignupTest.php index b6b5c63e..5fe5382d 100644 --- a/src/tests/Browser/SignupTest.php +++ b/src/tests/Browser/SignupTest.php @@ -1,267 +1,320 @@ delete(); + User::where('email', 'signuptestdusk@' . \config('app.domain'))->delete(); parent::tearDown(); } + /** + * Test signup code verification with a link + * + * @return void + */ + public function testSignupCodeByLink() + { + // Test invalid code (invalid format) + $this->browse(function (Browser $browser) { + // Register Signup page element selectors we'll be using + $browser->onWithoutAssert(new Signup()); + + // TODO: Test what happens if user is logged in + + $browser->visit('/signup/invalid-code'); + + // TODO: According to https://github.com/vuejs/vue-router/issues/977 + // it is not yet easily possible to display error page component (route) + // without changing the URL + // TODO: Instead of css selector we should probably define page/component + // and use it instead + $browser->waitFor('#error-page'); + }); + + // Test invalid code (valid format) + $this->browse(function (Browser $browser) { + $browser->visit('/signup/XXXXX-code'); + + // FIXME: User will not be able to continue anyway, so we should + // either display 1st step or 404 error page + $browser->waitFor('@step1'); + $browser->waitFor('.toast-error'); + $browser->click('.toast-error'); // remove the toast + }); + + // Test valid code + $this->browse(function (Browser $browser) { + $code = SignupCode::create([ + 'data' => [ + 'email' => 'User@example.org', + 'name' => 'User Name', + ] + ]); + + $browser->visit('/signup/' . $code->short_code . '-' . $code->code); + + $browser->waitFor('@step3'); + $browser->assertMissing('@step1'); + $browser->assertMissing('@step2'); + + // FIXME: Find a nice way to read javascript data without using hidden inputs + $this->assertSame($code->code, $browser->value('@step2 #signup_code')); + + // TODO: Test if the signup process can be completed + }); + } + /** * Test 1st step of the signup process * * @return void */ public function testSignupStep1() { $this->browse(function (Browser $browser) { $browser->visit(new Signup()); $browser->assertVisible('@step1'); // Here we expect two text inputs and Continue $browser->with('@step1', function ($step) { $step->assertVisible('#signup_name'); $step->assertFocused('#signup_name'); $step->assertVisible('#signup_email'); $step->assertVisible('[type=submit]'); }); // Submit empty form // Both Step 1 inputs are required, so after pressing Submit // we expect focus to be moved to the first input $browser->with('@step1', function ($step) { $step->click('[type=submit]'); $step->assertFocused('#signup_name'); }); // Submit invalid email // We expect email input to have is-invalid class added, with .invalid-feedback element $browser->with('@step1', function ($step) use ($browser) { $step->type('#signup_name', 'Test User'); $step->type('#signup_email', '@test'); $step->click('[type=submit]'); $step->waitFor('#signup_email.is-invalid'); $step->waitFor('#signup_email + .invalid-feedback'); $browser->waitFor('.toast-error'); $browser->click('.toast-error'); // remove the toast }); // Submit valid data // We expect error state on email input to be removed, and Step 2 form visible $browser->with('@step1', function ($step) { $step->type('#signup_name', 'Test User'); $step->type('#signup_email', 'BrowserSignupTestUser1@kolab.org'); $step->click('[type=submit]'); $step->assertMissing('#signup_email.is-invalid'); $step->assertMissing('#signup_email + .invalid-feedback'); $step->waitUntilMissing('#signup_code[value=""]'); }); $browser->waitFor('@step2'); $browser->assertMissing('@step1'); }); } /** * Test 2nd Step of the signup process * * @depends testSignupStep1 * @return void */ public function testSignupStep2() { $this->browse(function (Browser $browser) { $browser->assertVisible('@step2'); // Here we expect one text input, Back and Continue buttons $browser->with('@step2', function ($step) { $step->assertVisible('#signup_short_code'); $step->assertFocused('#signup_short_code'); $step->assertVisible('[type=button]'); $step->assertVisible('[type=submit]'); }); // Test Back button functionality $browser->click('@step2 [type=button]'); $browser->waitFor('@step1'); + $browser->assertFocused('@step1 #signup_name'); $browser->assertMissing('@step2'); // Submit valid Step 1 data (again) $browser->with('@step1', function ($step) { $step->type('#signup_name', 'Test User'); $step->type('#signup_email', 'BrowserSignupTestUser1@kolab.org'); $step->click('[type=submit]'); }); $browser->waitFor('@step2'); $browser->assertMissing('@step1'); // Submit invalid code // We expect code input to have is-invalid class added, with .invalid-feedback element $browser->with('@step2', function ($step) use ($browser) { $step->type('#signup_short_code', 'XXXXX'); $step->click('[type=submit]'); $browser->waitFor('.toast-error'); $step->assertVisible('#signup_short_code.is-invalid'); $step->assertVisible('#signup_short_code + .invalid-feedback'); $step->assertFocused('#signup_short_code'); $browser->click('.toast-error'); // remove the toast }); // Submit valid code // We expect error state on code input to be removed, and Step 3 form visible $browser->with('@step2', function ($step) { // Get the code and short_code from database // FIXME: Find a nice way to read javascript data without using hidden inputs $code = $step->value('#signup_code'); $this->assertNotEmpty($code); $code = SignupCode::find($code); $step->type('#signup_short_code', $code->short_code); $step->click('[type=submit]'); $step->assertMissing('#signup_short_code.is-invalid'); $step->assertMissing('#signup_short_code + .invalid-feedback'); }); $browser->waitFor('@step3'); $browser->assertMissing('@step2'); }); - - // TODO: Test code verification with an external link } /** * Test 3rd Step of the signup process * * @depends testSignupStep2 * @return void */ public function testSignupStep3() { $this->browse(function (Browser $browser) { $browser->assertVisible('@step3'); // Here we expect one text input, Back and Continue buttons $browser->with('@step3', function ($step) { $step->assertVisible('#signup_login'); $step->assertVisible('#signup_password'); $step->assertVisible('#signup_confirm'); $step->assertVisible('[type=button]'); $step->assertVisible('[type=submit]'); $step->assertFocused('#signup_login'); $step->assertSeeIn('#signup_login + span', '@' . \config('app.domain')); }); // Test Back button $browser->click('@step3 [type=button]'); $browser->waitFor('@step2'); + $browser->assertFocused('@step2 #signup_short_code'); $browser->assertMissing('@step3'); // TODO: Test form reset when going back // Submit valid code again $browser->with('@step2', function ($step) { $code = $step->value('#signup_code'); $this->assertNotEmpty($code); $code = SignupCode::find($code); $step->type('#signup_short_code', $code->short_code); $step->click('[type=submit]'); }); $browser->waitFor('@step3'); // Submit invalid data $browser->with('@step3', function ($step) use ($browser) { $step->assertFocused('#signup_login'); $step->type('#signup_login', '*'); $step->type('#signup_password', '12345678'); $step->type('#signup_confirm', '123456789'); $step->click('[type=submit]'); $browser->waitFor('.toast-error'); $step->assertVisible('#signup_login.is-invalid'); $step->assertVisible('#signup_login + span + .invalid-feedback'); $step->assertVisible('#signup_password.is-invalid'); $step->assertVisible('#signup_password + .invalid-feedback'); $step->assertFocused('#signup_login'); $browser->click('.toast-error'); // remove the toast }); // Submit invalid data (valid login, invalid password) $browser->with('@step3', function ($step) use ($browser) { - // Make sure the user does not exist (it may happen when executing - // tests again after failure) - User::where('email', 'SignupTestDusk@' . \config('app.domain'))->delete(); - // FIXME: For some reason I can't just use ->value() here $step->clear('#signup_login'); $step->type('#signup_login', 'SignupTestDusk'); $step->click('[type=submit]'); $browser->waitFor('.toast-error'); $step->assertVisible('#signup_password.is-invalid'); $step->assertVisible('#signup_password + .invalid-feedback'); $step->assertMissing('#signup_login.is-invalid'); $step->assertMissing('#signup_login + span + .invalid-feedback'); $step->assertFocused('#signup_password'); $browser->click('.toast-error'); // remove the toast }); // Submit valid data $browser->with('@step3', function ($step) { // FIXME: For some reason I can't just use ->value() here $step->clear('#signup_confirm'); $step->type('#signup_confirm', '12345678'); $step->click('[type=submit]'); }); $browser->waitUntilMissing('@step3'); // At this point we should be auto-logged-in to dashboard $dashboard = new Dashboard(); $dashboard->assert($browser); // FIXME: Is it enough to be sure user is logged in? }); } } diff --git a/src/tests/Feature/Controller/SignupTest.php b/src/tests/Feature/Controller/SignupTest.php index 8987411f..eddd25df 100644 --- a/src/tests/Feature/Controller/SignupTest.php +++ b/src/tests/Feature/Controller/SignupTest.php @@ -1,409 +1,405 @@ 'SignupControllerTest1@' . \config('app.domain')]); } /** * {@inheritDoc} * * @return void */ public function tearDown(): void { - User::where('email', 'SignupLogin@' . \config('app.domain')) + User::where('email', 'signuplogin@' . \config('app.domain')) ->orWhere('email', 'SignupControllerTest1@' . \config('app.domain')) ->delete(); parent::tearDown(); } /** * Test signup initialization with invalid input * * @return void */ public function testSignupInitInvalidInput() { // Empty input data $data = []; $response = $this->post('/api/auth/signup/init', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertArrayHasKey('email', $json['errors']); $this->assertArrayHasKey('name', $json['errors']); // Data with missing name $data = [ 'email' => 'UsersApiControllerTest1@UsersApiControllerTest.com', 'password' => 'simple123', 'password_confirmation' => 'simple123' ]; $response = $this->post('/api/auth/signup/init', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('name', $json['errors']); // Data with invalid email (but not phone number) $data = [ 'email' => '@example.org', 'name' => 'Signup User', 'password' => 'simple123', 'password_confirmation' => 'simple123' ]; $response = $this->post('/api/auth/signup/init', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('email', $json['errors']); // TODO: Test phone validation } /** * Test signup initialization with valid input * * @return array */ public function testSignupInitValidInput() { Queue::fake(); // Assert that no jobs were pushed... Queue::assertNothingPushed(); $data = [ 'email' => 'testuser@external.com', 'name' => 'Signup User', 'password' => 'simple123', 'password_confirmation' => 'simple123' ]; $response = $this->post('/api/auth/signup/init', $data); $json = $response->json(); $response->assertStatus(200); $this->assertCount(2, $json); $this->assertSame('success', $json['status']); $this->assertNotEmpty($json['code']); // Assert the email sending job was pushed once Queue::assertPushed(\App\Jobs\SignupVerificationEmail::class, 1); // Assert the job has proper data assigned Queue::assertPushed(\App\Jobs\SignupVerificationEmail::class, function ($job) use ($data, $json) { // Access protected property $reflection = new \ReflectionClass($job); $code = $reflection->getProperty('code'); $code->setAccessible(true); $code = $code->getValue($job); return $code->code === $json['code'] && $code->data['email'] === $data['email'] && $code->data['name'] === $data['name']; }); return [ 'code' => $json['code'], 'email' => $data['email'], 'name' => $data['name'], ]; } /** * Test signup code verification with invalid input * * @depends testSignupInitValidInput * @return void */ public function testSignupVerifyInvalidInput(array $result) { // Empty data $data = []; $response = $this->post('/api/auth/signup/verify', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertArrayHasKey('code', $json['errors']); $this->assertArrayHasKey('short_code', $json['errors']); // Data with existing code but missing short_code $data = [ 'code' => $result['code'], ]; $response = $this->post('/api/auth/signup/verify', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('short_code', $json['errors']); // Data with invalid short_code $data = [ 'code' => $result['code'], 'short_code' => 'XXXX', ]; $response = $this->post('/api/auth/signup/verify', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('short_code', $json['errors']); // TODO: Test expired code } /** * Test signup code verification with valid input * * @depends testSignupInitValidInput * * @return array */ public function testSignupVerifyValidInput(array $result) { $code = SignupCode::find($result['code']); $data = [ 'code' => $code->code, 'short_code' => $code->short_code, ]; $response = $this->post('/api/auth/signup/verify', $data); $json = $response->json(); $response->assertStatus(200); $this->assertCount(3, $json); $this->assertSame('success', $json['status']); $this->assertSame($result['email'], $json['email']); $this->assertSame($result['name'], $json['name']); return $result; } /** * Test last signup step with invalid input * * @depends testSignupVerifyValidInput * @return void */ public function testSignupInvalidInput(array $result) { // Empty data $data = []; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertArrayHasKey('login', $json['errors']); $this->assertArrayHasKey('password', $json['errors']); // Passwords do not match $data = [ 'login' => 'test', 'password' => 'test', 'password_confirmation' => 'test2', ]; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('password', $json['errors']); // Login too short $data = [ 'login' => '1', 'password' => 'test', 'password_confirmation' => 'test', ]; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('login', $json['errors']); // Login invalid $data = [ 'login' => 'żżżżż', 'password' => 'test', 'password_confirmation' => 'test', ]; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('login', $json['errors']); // Data with invalid short_code $data = [ 'login' => 'TestLogin', 'password' => 'test', 'password_confirmation' => 'test', 'code' => $result['code'], 'short_code' => 'XXXX', ]; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('short_code', $json['errors']); } /** * Test last signup step with valid input (user creation) * * @depends testSignupVerifyValidInput * @return void */ public function testSignupValidInput(array $result) { $identity = \strtolower('SignupLogin@') . \config('app.domain'); - // Make sure the user does not exist (it may happen when executing - // tests again after failure) - User::where('email', $identity)->delete(); - $code = SignupCode::find($result['code']); $data = [ 'login' => 'SignupLogin', 'password' => 'test', 'password_confirmation' => 'test', 'code' => $code->code, 'short_code' => $code->short_code, ]; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(200); $this->assertCount(4, $json); $this->assertSame('success', $json['status']); $this->assertSame('bearer', $json['token_type']); $this->assertTrue(!empty($json['expires_in']) && is_int($json['expires_in']) && $json['expires_in'] > 0); $this->assertNotEmpty($json['access_token']); // Check if the code has been removed $this->assertNull(SignupCode::where($result['code'])->first()); // Check if the user has been created $user = User::where('email', $identity)->first(); $this->assertNotEmpty($user); $this->assertSame($identity, $user->email); $this->assertSame($result['name'], $user->name); // Check external email in user settings $this->assertSame($result['email'], $user->getSetting('external_email', 'not set')); // TODO: Check if the access token works } /** * List of email address validation cases for testValidateEmail() * * @return array Arguments for testValidateEmail() */ public function dataValidateEmail() { // To access config from dataProvider method we have to refreshApplication() first $this->refreshApplication(); $domain = \config('app.domain'); return [ // general cases (invalid) ['', false, 'validation.emailinvalid'], ['example.org', false, 'validation.emailinvalid'], ['@example.org', false, 'validation.emailinvalid'], ['test@localhost', false, 'validation.emailinvalid'], // general cases (valid) ['test@domain.tld', false, null], ['&@example.org', false, null], // kolab identity cases ['admin@' . $domain, true, 'validation.emailexists'], ['administrator@' . $domain, true, 'validation.emailexists'], ['sales@' . $domain, true, 'validation.emailexists'], ['root@' . $domain, true, 'validation.emailexists'], ['&@' . $domain, true, 'validation.emailinvalid'], ['testnonsystemdomain@invalid.tld', true, 'validation.emailinvalid'], // existing account ['SignupControllerTest1@' . $domain, true, 'validation.emailexists'], // valid for signup ['test.test@' . $domain, true, null], ['test_test@' . $domain, true, null], ['test-test@' . $domain, true, null], ]; } /** * Signup email validation. * * Note: Technicly these are mostly unit tests, but let's keep it here for now. * FIXME: Shall we do a http request for each case? * * @dataProvider dataValidateEmail */ public function testValidateEmail($email, $signup, $expected_result) { $method = new \ReflectionMethod('App\Http\Controllers\API\SignupController', 'validateEmail'); $method->setAccessible(true); $is_phone = false; $result = $method->invoke(new SignupController(), $email, $signup); $this->assertSame($expected_result, $result); } }