diff --git a/src/app/Http/Controllers/API/AuthController.php b/src/app/Http/Controllers/API/AuthController.php index f68dca85..b92194c2 100644 --- a/src/app/Http/Controllers/API/AuthController.php +++ b/src/app/Http/Controllers/API/AuthController.php @@ -1,125 +1,130 @@ user(); $response = V4\UsersController::userResponse($user); if (!empty(request()->input('refresh_token'))) { // @phpstan-ignore-next-line return $this->respondWithToken(Auth::guard()->refresh(), $response); } return response()->json($response); } /** * Helper method for other controllers with user auto-logon * functionality * * @param \App\User $user User model object */ public static function logonResponse(User $user) { // @phpstan-ignore-next-line $token = Auth::guard()->login($user); + $response = V4\UsersController::userResponse($user); + $response['status'] = 'success'; - return self::respondWithToken($token, ['status' => 'success']); + return self::respondWithToken($token, $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) { // TODO: Redirect to dashboard if authenticated. $v = Validator::make( $request->all(), [ 'email' => 'required|min:2', 'password' => 'required|min:4', ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } $credentials = $request->only('email', 'password'); if ($token = Auth::guard()->attempt($credentials)) { - $sf = new \App\Auth\SecondFactor(Auth::guard()->user()); + $user = Auth::guard()->user(); + $sf = new \App\Auth\SecondFactor($user); if ($response = $sf->requestHandler($request)) { return $response; } - return $this->respondWithToken($token); + $response = V4\UsersController::userResponse($user); + + return $this->respondWithToken($token, $response); } return response()->json(['status' => 'error', 'message' => __('auth.failed')], 401); } /** * Log the user out (Invalidate the token) * * @return \Illuminate\Http\JsonResponse */ public function logout() { Auth::guard()->logout(); return response()->json([ 'status' => 'success', 'message' => __('auth.logoutsuccess') ]); } /** * Refresh a token. * * @return \Illuminate\Http\JsonResponse */ public function refresh() { // @phpstan-ignore-next-line return $this->respondWithToken(Auth::guard()->refresh()); } /** * Get the token array structure. * * @param string $token Respond with this token. * @param array $response Additional response data * * @return \Illuminate\Http\JsonResponse */ protected static function respondWithToken($token, array $response = []) { $response['access_token'] = $token; $response['token_type'] = 'bearer'; // @phpstan-ignore-next-line $response['expires_in'] = Auth::guard()->factory()->getTTL() * 60; return response()->json($response); } } diff --git a/src/resources/js/app.js b/src/resources/js/app.js index 20a13597..4eb7f011 100644 --- a/src/resources/js/app.js +++ b/src/resources/js/app.js @@ -1,349 +1,353 @@ /** * 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') import AppComponent from '../vue/App' import MenuComponent from '../vue/Widgets/Menu' import store from './store' const loader = '
Loading
' const app = new Vue({ el: '#app', components: { AppComponent, MenuComponent, }, store, router: window.router, data() { return { isLoading: true, isAdmin: window.isAdmin } }, methods: { // Clear (bootstrap) form validation state clearFormValidation(form) { $(form).find('.is-invalid').removeClass('is-invalid') $(form).find('.invalid-feedback').remove() }, isController(wallet_id) { if (wallet_id && store.state.authInfo) { let i for (i = 0; i < store.state.authInfo.wallets.length; i++) { if (wallet_id == store.state.authInfo.wallets[i].id) { return true } } for (i = 0; i < store.state.authInfo.accounts.length; i++) { if (wallet_id == store.state.authInfo.accounts[i].id) { return true } } } return false }, // Set user state to "logged in" loginUser(response, dashboard, update) { if (!update) { store.commit('logoutUser') // destroy old state data store.commit('loginUser') } localStorage.setItem('token', response.access_token) axios.defaults.headers.common.Authorization = 'Bearer ' + response.access_token + if (response.email) { + store.state.authInfo = response + } + if (dashboard !== false) { this.$router.push(store.state.afterLogin || { name: 'dashboard' }) } store.state.afterLogin = null // Refresh the token before it expires let timeout = response.expires_in || 0 // We'll refresh 60 seconds before the token expires if (timeout > 60) { timeout -= 60 } // TODO: We probably should try a few times in case of an error // TODO: We probably should prevent axios from doing any requests // while the token is being refreshed this.refreshTimeout = setTimeout(() => { axios.post('/api/auth/refresh').then(response => { this.loginUser(response.data, false, true) }) }, timeout * 1000) }, // Set user state to "not logged in" logoutUser() { store.commit('logoutUser') localStorage.setItem('token', '') delete axios.defaults.headers.common.Authorization this.$router.push({ name: 'login' }) clearTimeout(this.refreshTimeout) }, // Display "loading" overlay inside of the specified element addLoader(elem) { $(elem).css({position: 'relative'}).append($(loader).addClass('small')) }, // Remove loader element added in addLoader() removeLoader(elem) { $(elem).find('.app-loader').remove() }, startLoading() { this.isLoading = true // Lock the UI with the 'loading...' element let loading = $('#app > .app-loader').removeClass('fadeOut') if (!loading.length) { $('#app').append($(loader)) } }, // Hide "loading" overlay stopLoading() { $('#app > .app-loader').addClass('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}
` $('#error-page').remove() $('#app').append(error_page) }, errorHandler(error) { this.stopLoading() if (!error.response) { // TODO: probably network connection error } else if (error.response.status === 401) { this.logoutUser() } else { this.errorPage(error.response.status, error.response.statusText) } }, downloadFile(url) { // TODO: This might not be a best way for big files as the content // will be stored (temporarily) in browser memory // TODO: This method does not show the download progress in the browser // but it could be implemented in the UI, axios has 'progress' property axios.get(url, { responseType: 'blob' }) .then (response => { const link = document.createElement('a') const contentDisposition = response.headers['content-disposition'] let filename = 'unknown' if (contentDisposition) { const match = contentDisposition.match(/filename="(.+)"/); if (match.length === 2) { filename = match[1]; } } link.href = window.URL.createObjectURL(response.data) link.download = filename link.click() }) }, price(price, currency) { return ((price || 0) / 100).toLocaleString('de-DE', { style: 'currency', currency: currency || 'CHF' }) }, priceLabel(cost, units = 1, discount) { let index = '' if (units < 0) { units = 1 } if (discount) { cost = Math.floor(cost * ((100 - discount) / 100)) index = '\u00B9' } return this.price(cost * units) + '/month' + index }, clickRecord(event) { if (!/^(a|button|svg|path)$/i.test(event.target.nodeName)) { let link = $(event.target).closest('tr').find('a')[0] if (link) { link.click() } } }, domainStatusClass(domain) { if (domain.isDeleted) { return 'text-muted' } if (domain.isSuspended) { return 'text-warning' } if (!domain.isVerified || !domain.isLdapReady || !domain.isConfirmed) { return 'text-danger' } return 'text-success' }, domainStatusText(domain) { if (domain.isDeleted) { return 'Deleted' } if (domain.isSuspended) { return 'Suspended' } if (!domain.isVerified || !domain.isLdapReady || !domain.isConfirmed) { return 'Not Ready' } return 'Active' }, userStatusClass(user) { if (user.isDeleted) { return 'text-muted' } if (user.isSuspended) { return 'text-warning' } if (!user.isImapReady || !user.isLdapReady) { return 'text-danger' } return 'text-success' }, userStatusText(user) { if (user.isDeleted) { return 'Deleted' } if (user.isSuspended) { return 'Suspended' } if (!user.isImapReady || !user.isLdapReady) { return 'Not Ready' } return 'Active' } } }) // Add a axios request interceptor window.axios.interceptors.request.use( config => { // This is the only way I found to change configuration options // on a running application. We need this for browser testing. config.headers['X-Test-Payment-Provider'] = window.config.paymentProvider return config }, error => { // Do something with request error return Promise.reject(error) } ) // Add a axios response interceptor for general/validation error handler window.axios.interceptors.response.use( response => { // Do nothing return response }, error => { let error_msg let status = error.response ? error.response.status : 200 if (error.response && status == 422) { error_msg = "Form validation error" const modal = $('div.modal.show') $(modal.length ? modal : 'form').each((i, form) => { form = $(form) $.each(error.response.data.errors || {}, (idx, msg) => { const input_name = (form.data('validation-prefix') || form.find('form').first().data('validation-prefix') || '') + idx let input = form.find('#' + input_name) if (!input.length) { input = form.find('[name="' + input_name + '"]'); } if (input.length) { // Create an error message\ // API responses can use a string, array or object let msg_text = '' if ($.type(msg) !== 'string') { $.each(msg, (index, str) => { msg_text += str + ' ' }) } else { msg_text = msg } let feedback = $('
').text(msg_text) if (input.is('.list-input')) { // List input widget input.children(':not(:first-child)').each((index, element) => { if (msg[index]) { $(element).find('input').addClass('is-invalid') } }) input.addClass('is-invalid').next('.invalid-feedback').remove() input.after(feedback) } else { // Standard form element input.addClass('is-invalid') input.parent().find('.invalid-feedback').remove() input.parent().append(feedback) } } }) form.find('.is-invalid:not(.listinput-widget)').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.$toast.error(error_msg || "Server Error") // Pass the error as-is return Promise.reject(error) } ) diff --git a/src/resources/sass/app.scss b/src/resources/sass/app.scss index fbb7724f..87f10d47 100644 --- a/src/resources/sass/app.scss +++ b/src/resources/sass/app.scss @@ -1,398 +1,398 @@ @import 'variables'; @import 'bootstrap'; @import 'menu'; @import 'toast'; @import 'forms'; html, body, body > .outer-container { height: 100%; } #app { display: flex; flex-direction: column; min-height: 100%; & > nav { flex-shrink: 0; z-index: 12; } & > div.container { flex-grow: 1; margin-top: 2rem; margin-bottom: 2rem; } & > .filler { flex-grow: 1; } & > div.container + .filler { display: none; } } #error-page { position: absolute; top: 0; height: 100%; width: 100%; align-items: center; display: flex; justify-content: center; color: #636b6f; z-index: 10; background: white; .code { text-align: right; border-right: 2px solid; font-size: 26px; padding: 0 15px; } .message { font-size: 18px; padding: 0 15px; } } .app-loader { background-color: $body-bg; height: 100%; width: 100%; position: absolute; top: 0; left: 0; display: flex; align-items: center; justify-content: center; z-index: 8; .spinner-border { width: 120px; height: 120px; border-width: 15px; color: #b2aa99; } &.small .spinner-border { width: 25px; height: 25px; border-width: 3px; } &.fadeOut { visibility: hidden; opacity: 0; - transition: visibility 400ms linear, opacity 400ms linear; + transition: visibility 300ms linear, opacity 300ms linear; } } pre { margin: 1rem 0; padding: 1rem; background-color: $menu-bg-color; } .card-title { font-size: 1.2rem; font-weight: bold; } tfoot.table-fake-body { background-color: #f8f8f8; color: grey; text-align: center; td { vertical-align: middle; height: 8em; border: 0; } tbody:not(:empty) + & { display: none; } } table { td.buttons, td.email, td.price, td.datetime, td.selection { width: 1%; white-space: nowrap; } th.price, td.price { width: 1%; text-align: right; white-space: nowrap; } &.form-list { margin: 0; td { border: 0; &:first-child { padding-left: 0; } &:last-child { padding-right: 0; } } button { line-height: 1; } } .btn-action { line-height: 1; padding: 0; } } .list-details { min-height: 1em; & > ul { margin: 0; padding-left: 1.2em; } } .plan-selector { .plan-ico { font-size: 3.8rem; color: #f1a539; border: 3px solid #f1a539; width: 6rem; height: 6rem; margin-bottom: 1rem; border-radius: 50%; } } .plan-description { & > ul { padding-left: 1.2em; &:last-child { margin-bottom: 0; } } } #status-box { background-color: lighten($green, 35); .progress { background-color: #fff; height: 10px; } .progress-label { font-size: 0.9em; } .progress-bar { background-color: $green; } &.process-failed { background-color: lighten($orange, 30); .progress-bar { background-color: $red; } } } #dashboard-nav { display: flex; flex-wrap: wrap; justify-content: center; & > a { padding: 1rem; text-align: center; white-space: nowrap; margin: 0.25rem; text-decoration: none; width: 150px; &.disabled { pointer-events: none; opacity: 0.6; } .badge { position: absolute; top: 0.5rem; right: 0.5rem; } } svg { width: 6rem; height: 6rem; margin: auto; } } .form-separator { position: relative; margin: 1em 0; display: flex; justify-content: center; hr { border-color: #999; margin: 0; position: absolute; top: 0.75em; width: 100%; } span { background: #fff; padding: 0 1em; z-index: 1; } } // Various improvements for mobile @include media-breakpoint-down(sm) { .card { border: 0; } .card-body { padding: 0.5rem 0; } .form-group { margin-bottom: 0.5rem; } .nav-tabs { flex-wrap: nowrap; overflow-x: auto; .nav-link { white-space: nowrap; padding: 0.5rem 0.75rem; } } .tab-content { margin-top: 0.5rem; } .col-form-label { color: #666; font-size: 95%; } .form-group.plaintext .col-form-label { padding-bottom: 0; } form.read-only.short label { width: 35%; & + * { width: 65%; } } #app > div.container { margin-bottom: 1rem; margin-top: 1rem; max-width: 100%; } #header-menu-navbar { padding: 0; } #dashboard-nav > a { width: 135px; } .table-sm:not(.form-list) { tbody td { padding: 0.75rem 0.5rem; svg { vertical-align: -0.175em; } & > svg { font-size: 125%; margin-right: 0.25rem; } } } .table.transactions { thead { display: none; } tbody { tr { position: relative; display: flex; flex-wrap: wrap; } td { width: auto; border: 0; padding: 0.5rem; &.datetime { width: 50%; padding-left: 0; } &.description { order: 3; width: 100%; border-bottom: 1px solid $border-color; color: $secondary; padding: 0 1.5em 0.5rem 0; margin-top: -0.25em; } &.selection { position: absolute; right: 0; border: 0; top: 1.7em; padding-right: 0; } &.price { width: 50%; padding-right: 0; } &.email { display: none; } } } } } diff --git a/src/resources/vue/App.vue b/src/resources/vue/App.vue index 7bc5d640..3670b843 100644 --- a/src/resources/vue/App.vue +++ b/src/resources/vue/App.vue @@ -1,50 +1,49 @@ diff --git a/src/resources/vue/Dashboard.vue b/src/resources/vue/Dashboard.vue index cd942cce..93f2030a 100644 --- a/src/resources/vue/Dashboard.vue +++ b/src/resources/vue/Dashboard.vue @@ -1,68 +1,55 @@ diff --git a/src/tests/Feature/Controller/AuthTest.php b/src/tests/Feature/Controller/AuthTest.php index 71711486..87e5fd23 100644 --- a/src/tests/Feature/Controller/AuthTest.php +++ b/src/tests/Feature/Controller/AuthTest.php @@ -1,195 +1,201 @@ deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestDomain('userscontroller.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestDomain('userscontroller.com'); parent::tearDown(); } /** * Test fetching current user info (/api/auth/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"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals($user->id, $json['id']); $this->assertEquals($user->email, $json['email']); $this->assertEquals(User::STATUS_NEW | User::STATUS_ACTIVE, $json['status']); $this->assertTrue(is_array($json['statusInfo'])); $this->assertTrue(is_array($json['settings'])); $this->assertTrue(is_array($json['aliases'])); $this->assertTrue(!isset($json['access_token'])); // Note: Details of the content are tested in testUserResponse() // Test token refresh via the info request // First we log in as we need the token (actingAs() will not work) $post = ['email' => 'john@kolab.org', 'password' => 'simple123']; $response = $this->post("api/auth/login", $post); $json = $response->json(); $response = $this->withHeaders(['Authorization' => 'Bearer ' . $json['access_token']]) ->get("api/auth/info?refresh_token=1"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals('john@kolab.org', $json['email']); $this->assertTrue(is_array($json['statusInfo'])); $this->assertTrue(is_array($json['settings'])); $this->assertTrue(is_array($json['aliases'])); $this->assertTrue(!empty($json['access_token'])); $this->assertTrue(!empty($json['expires_in'])); } /** * Test /api/auth/login */ public function testLogin(): string { // Request with no data $response = $this->post("api/auth/login", []); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertArrayHasKey('email', $json['errors']); $this->assertArrayHasKey('password', $json['errors']); // Request with invalid password $post = ['email' => 'john@kolab.org', 'password' => 'wrong']; $response = $this->post("api/auth/login", $post); $response->assertStatus(401); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame('Invalid username or password.', $json['message']); // Valid user+password + $user = $this->getTestUser('john@kolab.org'); $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']); + $this->assertEquals($user->id, $json['id']); + $this->assertEquals($user->email, $json['email']); + $this->assertTrue(is_array($json['statusInfo'])); + $this->assertTrue(is_array($json['settings'])); + $this->assertTrue(is_array($json['aliases'])); // Valid user+password (upper-case) $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']); // TODO: We have browser tests for 2FA but we should probably also test it here return $json['access_token']; } /** * Test /api/auth/logout * * @depends testLogin */ public function testLogout($token): void { // Request with no token, testing that it requires auth $response = $this->post("api/auth/logout"); $response->assertStatus(401); // Test the same using JSON mode $response = $this->json('POST', "api/auth/logout", []); $response->assertStatus(401); // Request with valid token $response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])->post("api/auth/logout"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals('success', $json['status']); $this->assertEquals('Successfully logged out.', $json['message']); // Check if it really destroyed the token? $response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])->get("api/auth/info"); $response->assertStatus(401); } /** * Test /api/auth/refresh */ public function testRefresh(): void { // Request with no token, testing that it requires auth $response = $this->post("api/auth/refresh"); $response->assertStatus(401); // Test the same using JSON mode $response = $this->json('POST', "api/auth/refresh", []); $response->assertStatus(401); // Login the user to get a valid token $post = ['email' => 'john@kolab.org', 'password' => 'simple123']; $response = $this->post("api/auth/login", $post); $response->assertStatus(200); $json = $response->json(); $token = $json['access_token']; // Request with a valid token $response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])->post("api/auth/refresh"); $response->assertStatus(200); $json = $response->json(); $this->assertTrue(!empty($json['access_token'])); $this->assertTrue($json['access_token'] != $token); $this->assertEquals(\config('jwt.ttl') * 60, $json['expires_in']); $this->assertEquals('bearer', $json['token_type']); $new_token = $json['access_token']; // TODO: Shall we invalidate the old token? // And if the new token is working $response = $this->withHeaders(['Authorization' => 'Bearer ' . $new_token])->get("api/auth/info"); $response->assertStatus(200); } } diff --git a/src/tests/Feature/Controller/PasswordResetTest.php b/src/tests/Feature/Controller/PasswordResetTest.php index 046538e7..34ea44b4 100644 --- a/src/tests/Feature/Controller/PasswordResetTest.php +++ b/src/tests/Feature/Controller/PasswordResetTest.php @@ -1,329 +1,330 @@ deleteTestUser('passwordresettest@' . \config('app.domain')); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('passwordresettest@' . \config('app.domain')); parent::tearDown(); } /** * Test password-reset/init with invalid input */ public function testPasswordResetInitInvalidInput(): void { // Empty input data $data = []; $response = $this->post('/api/auth/password-reset/init', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('email', $json['errors']); // Data with invalid email $data = [ 'email' => '@example.org', ]; $response = $this->post('/api/auth/password-reset/init', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('email', $json['errors']); // Data with valid but non-existing email $data = [ 'email' => 'non-existing-password-reset@example.org', ]; $response = $this->post('/api/auth/password-reset/init', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('email', $json['errors']); // Data with valid email af an existing user with no external email $data = [ 'email' => 'passwordresettest@' . \config('app.domain'), ]; $response = $this->post('/api/auth/password-reset/init', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('email', $json['errors']); } /** * Test password-reset/init with valid input * * @return array */ public function testPasswordResetInitValidInput() { Queue::fake(); // Assert that no jobs were pushed... Queue::assertNothingPushed(); // Add required external email address to user settings $user = $this->getTestUser('passwordresettest@' . \config('app.domain')); $user->setSetting('external_email', 'ext@email.com'); $data = [ 'email' => 'passwordresettest@' . \config('app.domain'), ]; $response = $this->post('/api/auth/password-reset/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\PasswordResetEmail::class, 1); // Assert the job has proper data assigned Queue::assertPushed(\App\Jobs\PasswordResetEmail::class, function ($job) use ($user, &$code, $json) { $code = TestCase::getObjectProperty($job, 'code'); return $code->user->id == $user->id && $code->code == $json['code']; }); return [ 'code' => $code ]; } /** * Test password-reset/verify with invalid input * * @return void */ public function testPasswordResetVerifyInvalidInput() { // Empty data $data = []; $response = $this->post('/api/auth/password-reset/verify', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertArrayHasKey('short_code', $json['errors']); // Add verification code and required external email address to user settings $user = $this->getTestUser('passwordresettest@' . \config('app.domain')); $code = new VerificationCode(['mode' => 'password-reset']); $user->verificationcodes()->save($code); // Data with existing code but missing short_code $data = [ 'code' => $code->code, ]; $response = $this->post('/api/auth/password-reset/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 code $data = [ 'short_code' => '123456789', 'code' => $code->code, ]; $response = $this->post('/api/auth/password-reset/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 password-reset/verify with valid input * * @return void */ public function testPasswordResetVerifyValidInput() { // Add verification code and required external email address to user settings $user = $this->getTestUser('passwordresettest@' . \config('app.domain')); $code = new VerificationCode(['mode' => 'password-reset']); $user->verificationcodes()->save($code); // Data with invalid code $data = [ 'short_code' => $code->short_code, 'code' => $code->code, ]; $response = $this->post('/api/auth/password-reset/verify', $data); $json = $response->json(); $response->assertStatus(200); $this->assertCount(1, $json); $this->assertSame('success', $json['status']); } /** * Test password-reset with invalid input * * @return void */ public function testPasswordResetInvalidInput() { // Empty data $data = []; $response = $this->post('/api/auth/password-reset', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('password', $json['errors']); $user = $this->getTestUser('passwordresettest@' . \config('app.domain')); $code = new VerificationCode(['mode' => 'password-reset']); $user->verificationcodes()->save($code); // Data with existing code but missing password $data = [ 'code' => $code->code, ]; $response = $this->post('/api/auth/password-reset', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('password', $json['errors']); // Data with existing code but wrong password confirmation $data = [ 'code' => $code->code, 'short_code' => $code->short_code, 'password' => 'password', 'password_confirmation' => 'passwrong', ]; $response = $this->post('/api/auth/password-reset', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('password', $json['errors']); // Data with invalid short code $data = [ 'code' => $code->code, 'short_code' => '123456789', 'password' => 'password', 'password_confirmation' => 'password', ]; $response = $this->post('/api/auth/password-reset', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('short_code', $json['errors']); } /** * Test password reset with valid input * * @return void */ public function testPasswordResetValidInput() { $user = $this->getTestUser('passwordresettest@' . \config('app.domain')); $code = new VerificationCode(['mode' => 'password-reset']); $user->verificationcodes()->save($code); Queue::fake(); Queue::assertNothingPushed(); $data = [ 'password' => 'test', 'password_confirmation' => 'test', 'code' => $code->code, 'short_code' => $code->short_code, ]; $response = $this->post('/api/auth/password-reset', $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']); + $this->assertSame($user->email, $json['email']); + $this->assertSame($user->id, $json['id']); Queue::assertPushed(\App\Jobs\UserUpdate::class, 1); Queue::assertPushed(\App\Jobs\UserUpdate::class, function ($job) use ($user) { $job_user = TestCase::getObjectProperty($job, 'user'); return $job_user->id == $user->id && $job_user->email == $user->email && $job_user->password_ldap != $user->password_ldap; }); // Check if the code has been removed $this->assertNull(VerificationCode::find($code->code)); // TODO: Check password before and after (?) // TODO: Check if the access token works } } diff --git a/src/tests/Feature/Controller/SignupTest.php b/src/tests/Feature/Controller/SignupTest.php index 6754b19b..42f26c55 100644 --- a/src/tests/Feature/Controller/SignupTest.php +++ b/src/tests/Feature/Controller/SignupTest.php @@ -1,678 +1,678 @@ domain = $this->getPublicDomain(); $this->deleteTestUser("SignupControllerTest1@$this->domain"); $this->deleteTestUser("signuplogin@$this->domain"); $this->deleteTestUser("admin@external.com"); $this->deleteTestDomain('external.com'); $this->deleteTestDomain('signup-domain.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser("SignupControllerTest1@$this->domain"); $this->deleteTestUser("signuplogin@$this->domain"); $this->deleteTestUser("admin@external.com"); $this->deleteTestDomain('external.com'); $this->deleteTestDomain('signup-domain.com'); parent::tearDown(); } /** * Return a public domain for signup tests */ private function getPublicDomain(): string { if (!$this->domain) { $this->refreshApplication(); $public_domains = Domain::getPublicDomains(); $this->domain = reset($public_domains); if (empty($this->domain)) { $this->domain = 'signup-domain.com'; Domain::create([ 'namespace' => $this->domain, 'status' => Domain::STATUS_ACTIVE, 'type' => Domain::TYPE_PUBLIC, ]); } } return $this->domain; } /** * Test fetching plans for signup * * @return void */ public function testSignupPlans() { $response = $this->get('/api/auth/signup/plans'); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertCount(2, $json['plans']); $this->assertArrayHasKey('title', $json['plans'][0]); $this->assertArrayHasKey('name', $json['plans'][0]); $this->assertArrayHasKey('description', $json['plans'][0]); $this->assertArrayHasKey('button', $json['plans'][0]); } /** * 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(1, $json['errors']); $this->assertArrayHasKey('email', $json['errors']); // Data with missing name $data = [ 'email' => 'UsersApiControllerTest1@UsersApiControllerTest.com', 'first_name' => str_repeat('a', 250), 'last_name' => str_repeat('a', 250), ]; $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('first_name', $json['errors']); $this->assertArrayHasKey('last_name', $json['errors']); // Data with invalid email (but not phone number) $data = [ 'email' => '@example.org', 'first_name' => 'Signup', 'last_name' => 'User', ]; $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']); // Sanity check on voucher code, last/first name is optional $data = [ 'voucher' => '123456789012345678901234567890123', 'email' => 'valid@email.com', ]; $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('voucher', $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', 'first_name' => 'Signup', 'last_name' => 'User', 'plan' => 'individual', ]; $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) { $code = TestCase::getObjectProperty($job, 'code'); return $code->code === $json['code'] && $code->data['plan'] === $data['plan'] && $code->data['email'] === $data['email'] && $code->data['first_name'] === $data['first_name'] && $code->data['last_name'] === $data['last_name']; }); // Try the same with voucher $data['voucher'] = 'TEST'; $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 job has proper data assigned Queue::assertPushed(\App\Jobs\SignupVerificationEmail::class, function ($job) use ($data, $json) { $code = TestCase::getObjectProperty($job, 'code'); return $code->code === $json['code'] && $code->data['plan'] === $data['plan'] && $code->data['email'] === $data['email'] && $code->data['voucher'] === $data['voucher'] && $code->data['first_name'] === $data['first_name'] && $code->data['last_name'] === $data['last_name']; }); return [ 'code' => $json['code'], 'email' => $data['email'], 'first_name' => $data['first_name'], 'last_name' => $data['last_name'], 'plan' => $data['plan'], 'voucher' => $data['voucher'] ]; } /** * 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(7, $json); $this->assertSame('success', $json['status']); $this->assertSame($result['email'], $json['email']); $this->assertSame($result['first_name'], $json['first_name']); $this->assertSame($result['last_name'], $json['last_name']); $this->assertSame($result['voucher'], $json['voucher']); $this->assertSame(false, $json['is_domain']); $this->assertTrue(is_array($json['domains']) && !empty($json['domains'])); 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(3, $json['errors']); $this->assertArrayHasKey('login', $json['errors']); $this->assertArrayHasKey('password', $json['errors']); $this->assertArrayHasKey('domain', $json['errors']); $domain = $this->getPublicDomain(); // Passwords do not match and missing domain $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(2, $json['errors']); $this->assertArrayHasKey('password', $json['errors']); $this->assertArrayHasKey('domain', $json['errors']); $domain = $this->getPublicDomain(); // Login too short $data = [ 'login' => '1', 'domain' => $domain, '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']); // Missing codes $data = [ 'login' => 'login-valid', 'domain' => $domain, '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(2, $json['errors']); $this->assertArrayHasKey('code', $json['errors']); $this->assertArrayHasKey('short_code', $json['errors']); // Data with invalid short_code $data = [ 'login' => 'TestLogin', 'domain' => $domain, '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']); $code = SignupCode::find($result['code']); // Data with invalid voucher $data = [ 'login' => 'TestLogin', 'domain' => $domain, 'password' => 'test', 'password_confirmation' => 'test', 'code' => $result['code'], 'short_code' => $code->short_code, 'voucher' => 'XXX', ]; $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('voucher', $json['errors']); // Valid code, invalid login $data = [ 'login' => 'żżżżżż', 'domain' => $domain, 'password' => 'test', 'password_confirmation' => 'test', 'code' => $result['code'], 'short_code' => $code->short_code, ]; $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']); } /** * Test last signup step with valid input (user creation) * * @depends testSignupVerifyValidInput * @return void */ public function testSignupValidInput(array $result) { $queue = Queue::fake(); $domain = $this->getPublicDomain(); $identity = \strtolower('SignupLogin@') . $domain; $code = SignupCode::find($result['code']); $data = [ 'login' => 'SignupLogin', 'domain' => $domain, 'password' => 'test', 'password_confirmation' => 'test', 'code' => $code->code, 'short_code' => $code->short_code, 'voucher' => 'TEST', ]; $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']); + $this->assertSame($identity, $json['email']); Queue::assertPushed(\App\Jobs\UserCreate::class, 1); Queue::assertPushed(\App\Jobs\UserCreate::class, function ($job) use ($data) { $job_user = TestCase::getObjectProperty($job, 'user'); return $job_user->email === \strtolower($data['login'] . '@' . $data['domain']); }); // Check if the code has been removed $this->assertNull(SignupCode::where('code', $result['code'])->first()); // Check if the user has been created $user = User::where('email', $identity)->first(); $this->assertNotEmpty($user); $this->assertSame($identity, $user->email); // Check user settings $this->assertSame($result['first_name'], $user->getSetting('first_name')); $this->assertSame($result['last_name'], $user->getSetting('last_name')); $this->assertSame($result['email'], $user->getSetting('external_email')); // Discount $discount = Discount::where('code', 'TEST')->first(); $this->assertSame($discount->id, $user->wallets()->first()->discount_id); // TODO: Check SKUs/Plan // TODO: Check if the access token works } /** * Test signup for a group (custom domain) account * * @return void */ public function testSignupGroupAccount() { Queue::fake(); // Initial signup request $user_data = $data = [ 'email' => 'testuser@external.com', 'first_name' => 'Signup', 'last_name' => 'User', 'plan' => 'group', ]; $response = $this->withoutMiddleware()->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) { $code = TestCase::getObjectProperty($job, 'code'); return $code->code === $json['code'] && $code->data['plan'] === $data['plan'] && $code->data['email'] === $data['email'] && $code->data['first_name'] === $data['first_name'] && $code->data['last_name'] === $data['last_name']; }); // Verify the code $code = SignupCode::find($json['code']); $data = [ 'code' => $code->code, 'short_code' => $code->short_code, ]; $response = $this->post('/api/auth/signup/verify', $data); $result = $response->json(); $response->assertStatus(200); $this->assertCount(7, $result); $this->assertSame('success', $result['status']); $this->assertSame($user_data['email'], $result['email']); $this->assertSame($user_data['first_name'], $result['first_name']); $this->assertSame($user_data['last_name'], $result['last_name']); $this->assertSame(null, $result['voucher']); $this->assertSame(true, $result['is_domain']); $this->assertSame([], $result['domains']); // Final signup request $login = 'admin'; $domain = 'external.com'; $data = [ 'login' => $login, 'domain' => $domain, 'password' => 'test', 'password_confirmation' => 'test', 'code' => $code->code, 'short_code' => $code->short_code, ]; $response = $this->post('/api/auth/signup', $data); $result = $response->json(); $response->assertStatus(200); - $this->assertCount(4, $result); $this->assertSame('success', $result['status']); $this->assertSame('bearer', $result['token_type']); $this->assertTrue(!empty($result['expires_in']) && is_int($result['expires_in']) && $result['expires_in'] > 0); $this->assertNotEmpty($result['access_token']); + $this->assertSame("$login@$domain", $result['email']); Queue::assertPushed(\App\Jobs\DomainCreate::class, 1); Queue::assertPushed(\App\Jobs\DomainCreate::class, function ($job) use ($domain) { $job_domain = TestCase::getObjectProperty($job, 'domain'); return $job_domain->namespace === $domain; }); Queue::assertPushed(\App\Jobs\UserCreate::class, 1); Queue::assertPushed(\App\Jobs\UserCreate::class, function ($job) use ($data) { $job_user = TestCase::getObjectProperty($job, 'user'); return $job_user->email === $data['login'] . '@' . $data['domain']; }); // Check if the code has been removed $this->assertNull(SignupCode::find($code->id)); // Check if the user has been created $user = User::where('email', $login . '@' . $domain)->first(); $this->assertNotEmpty($user); // Check user settings $this->assertSame($user_data['email'], $user->getSetting('external_email')); $this->assertSame($user_data['first_name'], $user->getSetting('first_name')); $this->assertSame($user_data['last_name'], $user->getSetting('last_name')); // TODO: Check domain record // TODO: Check SKUs/Plan // TODO: Check if the access token works } /** * List of login/domain validation cases for testValidateLogin() * * @return array Arguments for testValidateLogin() */ public function dataValidateLogin(): array { $domain = $this->getPublicDomain(); return [ // Individual account ['', $domain, false, ['login' => 'The login field is required.']], ['test123456', 'localhost', false, ['domain' => 'The specified domain is invalid.']], ['test123456', 'unknown-domain.org', false, ['domain' => 'The specified domain is invalid.']], ['test.test', $domain, false, null], ['test_test', $domain, false, null], ['test-test', $domain, false, null], ['admin', $domain, false, ['login' => 'The specified login is not available.']], ['administrator', $domain, false, ['login' => 'The specified login is not available.']], ['sales', $domain, false, ['login' => 'The specified login is not available.']], ['root', $domain, false, ['login' => 'The specified login is not available.']], // TODO existing (public domain) user // ['signuplogin', $domain, false, ['login' => 'The specified login is not available.']], // Domain account ['admin', 'kolabsys.com', true, null], ['testnonsystemdomain', 'invalid', true, ['domain' => 'The specified domain is invalid.']], ['testnonsystemdomain', '.com', true, ['domain' => 'The specified domain is invalid.']], // existing custom domain ['jack', 'kolab.org', true, ['domain' => 'The specified domain is not available.']], ]; } /** * Signup login/domain validation. * * Note: Technically these include unit tests, but let's keep it here for now. * FIXME: Shall we do a http request for each case? * * @dataProvider dataValidateLogin */ public function testValidateLogin($login, $domain, $external, $expected_result): void { $result = $this->invokeMethod(new SignupController(), 'validateLogin', [$login, $domain, $external]); $this->assertSame($expected_result, $result); } }