diff --git a/src/app/Http/Controllers/API/PasswordResetController.php b/src/app/Http/Controllers/API/PasswordResetController.php --- a/src/app/Http/Controllers/API/PasswordResetController.php +++ b/src/app/Http/Controllers/API/PasswordResetController.php @@ -83,7 +83,7 @@ } // Validate the verification code - $code = VerificationCode::find($request->code); + $code = VerificationCode::where('code', $request->code)->where('active', true)->first(); if ( empty($code) @@ -140,4 +140,67 @@ return AuthController::logonResponse($user, $request->password); } + + /** + * Create a verification code for the current user. + * + * @param \Illuminate\Http\Request $request HTTP request + * + * @return \Illuminate\Http\JsonResponse JSON response + */ + public function codeCreate(Request $request) + { + // Generate the verification code + $code = new VerificationCode(); + $code->mode = 'password-reset'; + + // These codes are valid for 24 hours + $code->expires_at = now()->addHours(24); + + // The code is inactive until it is submitted via a different endpoint + $code->active = false; + + $this->guard()->user()->verificationcodes()->save($code); + + return response()->json([ + 'status' => 'success', + 'code' => $code->code, + 'short_code' => $code->short_code, + 'expires_at' => $code->expires_at->toDateTimeString(), + ]); + } + + /** + * Delete a verification code. + * + * @param string $id Code identifier + * + * @return \Illuminate\Http\JsonResponse The response + */ + public function codeDelete($id) + { + // Accept - input + if (strpos($id, '-')) { + $id = explode('-', $id)[0]; + } + + $code = VerificationCode::find($id); + + if (!$code) { + return $this->errorResponse(404); + } + + $current_user = $this->guard()->user(); + + if (empty($code->user) || !$current_user->canUpdate($code->user)) { + return $this->errorResponse(403); + } + + $code->delete(); + + return response()->json([ + 'status' => 'success', + 'message' => \trans('app.password-reset-code-delete-success'), + ]); + } } diff --git a/src/app/Http/Controllers/API/V4/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php --- a/src/app/Http/Controllers/API/V4/UsersController.php +++ b/src/app/Http/Controllers/API/V4/UsersController.php @@ -45,6 +45,8 @@ /** @var array Common object properties in the API response */ protected $objectProps = ['email']; + /** @var ?\App\VerificationCode Password reset code to activate on user create/update */ + protected $passCode; /** * Listing of users. @@ -131,6 +133,14 @@ $response['skus'] = \App\Entitlement::objectEntitlementsSummary($user); $response['config'] = $user->getConfig(); + $code = $user->verificationcodes()->where('active', true) + ->where('expires_at', '>', \Carbon\Carbon::now()) + ->first(); + + if ($code) { + $response['passwordLinkCode'] = $code->code . '-' . $code->short_code; + } + return response()->json($response); } @@ -230,6 +240,8 @@ 'password' => $request->password, ]); + $this->activatePassCode($user); + $owner->assignPackage($package, $user); if (!empty($settings)) { @@ -293,6 +305,8 @@ $user->save(); } + $this->activatePassCode($user); + if (isset($request->aliases)) { $user->setAliases($request->aliases); } @@ -457,8 +471,28 @@ 'aliases' => 'array|nullable', ]; + // Handle generated password reset code + if ($code = $request->input('passwordLinkCode')) { + // Accept - input + if (strpos($code, '-')) { + $code = explode('-', $code)[0]; + } + + $this->passCode = $this->guard()->user()->verificationcodes() + ->where('code', $code)->where('active', false)->first(); + + // Generate a password for a new user with password reset link + // FIXME: Should/can we have a user with no password set? + if ($this->passCode && empty($user)) { + $request->password = $request->password_confirmation = Str::random(16); + $ignorePassword = true; + } + } + if (empty($user) || !empty($request->password) || !empty($request->password_confirmation)) { - $rules['password'] = 'required|min:4|max:2048|confirmed'; + if (empty($ignorePassword)) { + $rules['password'] = 'required|min:6|max:255|confirmed'; + } } $errors = []; @@ -723,4 +757,19 @@ return null; } + + /** + * Activate password reset code (if set), and assign it to a user. + * + * @param \App\User $user The user + */ + protected function activatePassCode(User $user): void + { + // Activate the password reset code + if ($this->passCode) { + $this->passCode->user_id = $user->id; + $this->passCode->active = true; + $this->passCode->save(); + } + } } diff --git a/src/app/Observers/VerificationCodeObserver.php b/src/app/Observers/VerificationCodeObserver.php --- a/src/app/Observers/VerificationCodeObserver.php +++ b/src/app/Observers/VerificationCodeObserver.php @@ -34,6 +34,15 @@ } } - $code->expires_at = Carbon::now()->addHours($exp_hours); + if (empty($code->expires_at)) { + $code->expires_at = Carbon::now()->addHours($exp_hours); + } + + // Verification codes are active by default + // Note: This is not required, but this way we make sure the property value + // is a boolean not null after create() call, if it wasn't specified there. + if (!isset($code->active)) { + $code->active = true; + } } } diff --git a/src/app/VerificationCode.php b/src/app/VerificationCode.php --- a/src/app/VerificationCode.php +++ b/src/app/VerificationCode.php @@ -8,11 +8,13 @@ /** * The eloquent definition of a VerificationCode * - * @property string $code - * @property string $mode - * @property \App\User $user - * @property int $user_id - * @property string $short_code + * @property bool $active Active status + * @property string $code The code + * @property \Carbon\Carbon $expires_at Expiration date-time + * @property string $mode Mode, e.g. password-reset + * @property \App\User $user User object + * @property int $user_id User identifier + * @property string $short_code Short code */ class VerificationCode extends Model { @@ -53,18 +55,21 @@ public $timestamps = false; /** - * The attributes that are mass assignable. + * Casts properties as type * * @var array */ - protected $fillable = ['user_id', 'code', 'short_code', 'mode', 'expires_at']; + protected $casts = [ + 'active' => 'boolean', + 'expires_at' => 'datetime', + ]; /** - * The attributes that should be mutated to dates. + * The attributes that are mass assignable. * * @var array */ - protected $dates = ['expires_at']; + protected $fillable = ['user_id', 'code', 'short_code', 'mode', 'expires_at', 'active']; /** @@ -91,7 +96,7 @@ } /** - * The user to which this setting belongs. + * The user to which this code belongs. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ diff --git a/src/database/migrations/2022_01_13_100000_verification_code_active_column.php b/src/database/migrations/2022_01_13_100000_verification_code_active_column.php new file mode 100644 --- /dev/null +++ b/src/database/migrations/2022_01_13_100000_verification_code_active_column.php @@ -0,0 +1,39 @@ +boolean('active')->default(true); + } + ); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table( + 'verification_codes', + function (Blueprint $table) { + $table->dropColumn('active'); + } + ); + } +} diff --git a/src/resources/js/fontawesome.js b/src/resources/js/fontawesome.js --- a/src/resources/js/fontawesome.js +++ b/src/resources/js/fontawesome.js @@ -4,6 +4,7 @@ //import { } from '@fortawesome/free-brands-svg-icons' import { faCheckSquare, + faClipboard, faCreditCard, faSquare, } from '@fortawesome/free-regular-svg-icons' @@ -43,6 +44,7 @@ faCheck, faCheckCircle, faCheckSquare, + faClipboard, faCog, faComments, faCreditCard, diff --git a/src/resources/lang/en/app.php b/src/resources/lang/en/app.php --- a/src/resources/lang/en/app.php +++ b/src/resources/lang/en/app.php @@ -109,6 +109,8 @@ 'wallet-penalty-success' => 'The penalty has been added to the wallet successfully.', 'wallet-update-success' => 'User wallet updated successfully.', + 'password-reset-code-delete-success' => 'Password reset code deleted successfully.', + 'wallet-notice-date' => 'With your current subscriptions your account balance will last until about :date (:days).', 'wallet-notice-nocredit' => 'You are out of credit, top up your balance now.', 'wallet-notice-today' => 'You will run out of credit today, top up your balance now.', diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php --- a/src/resources/lang/en/ui.php +++ b/src/resources/lang/en/ui.php @@ -19,6 +19,7 @@ 'cancel' => "Cancel", 'close' => "Close", 'continue' => "Continue", + 'copy' => "Copy", 'delete' => "Delete", 'deny' => "Deny", 'download' => "Download", @@ -419,6 +420,9 @@ 'new' => "New user account", 'org' => "Organization", 'package' => "Package", + 'pass-input' => "Enter password", + 'pass-link' => "Set via link", + 'pass-link-label' => "Link:", 'price' => "Price", 'profile-title' => "Your profile", 'profile-delete' => "Delete account", diff --git a/src/resources/vue/User/Info.vue b/src/resources/vue/User/Info.vue --- a/src/resources/vue/User/Info.vue +++ b/src/resources/vue/User/Info.vue @@ -68,13 +68,33 @@
- -
-
-
- -
- +
+ + + + +
+
+ + +
+
@@ -144,11 +164,21 @@ }, data() { return { + passwordLinkCode: '', + passwordMode: '', user_id: null, user: { aliases: [], config: [] }, status: {} } }, + computed: { + isSelf: function () { + return this.user_id == this.$store.state.authInfo.id + }, + passwordLink: function () { + return this.$root.appUrl + '/password-reset/' + this.passwordLinkCode + } + }, created() { this.user_id = this.$route.params.user @@ -164,8 +194,16 @@ this.user.last_name = response.data.settings.last_name this.user.organization = response.data.settings.organization this.status = response.data.statusInfo + + this.passwordLinkCode = this.user.passwordLinkCode }) .catch(this.$root.errorHandler) + + if (this.isSelf) { + this.passwordMode = 'input' + } + } else { + this.passwordMode = 'input' } }, mounted() { @@ -175,11 +213,64 @@ }) }, methods: { + passwordLinkCopy() { + navigator.clipboard.writeText($('#password-link code').text()); + }, + passwordLinkDelete() { + this.passwordMode = '' + $('#pass-mode-link')[0].checked = false + + // Delete the code for real + axios.delete('/api/v4/password-reset/code/' + this.passwordLinkCode) + .then(response => { + this.passwordLinkCode = '' + this.user.passwordLinkCode = '' + if (response.data.status == 'success') { + this.$toast.success(response.data.message) + } + }) + }, + setPasswordMode(event) { + const mode = event.target.checked ? event.target.value : '' + + // In the "new user" mode the password mode cannot be unchecked + if (!mode && this.user_id === 'new') { + event.target.checked = true + return + } + + this.passwordMode = mode + + if (!event.target.checked) { + return + } + + $('#pass-mode-' + (mode == 'link' ? 'input' : 'link'))[0].checked = false + + // Note: we use $nextTick() becouse we have to wait for the HTML elements to exist + this.$nextTick().then(() => { + if (mode == 'link' && !this.passwordLinkCode) { + const element = $('#password-link') + this.$root.addLoader(element) + axios.post('/api/v4/password-reset/code', []) + .then(response => { + this.$root.removeLoader(element) + this.passwordLinkCode = response.data.code + '-' + response.data.short_code + }) + .catch(error => { + this.$root.removeLoader(element) + }) + } else if (mode == 'input') { + $('#password').focus(); + } + }) + }, submit() { this.$root.clearFormValidation($('#general form')) let method = 'post' let location = '/api/v4/users' + let post = this.$root.pick(this.user, ['aliases', 'email', 'first_name', 'last_name', 'organization']) if (this.user_id !== 'new') { method = 'put' @@ -192,12 +283,19 @@ skus[id] = range || 1 }) - this.user.skus = skus + post.skus = skus } else { - this.user.package = $('#user-packages input:checked').val() + post.package = $('#user-packages input:checked').val() + } + + if (this.passwordMode == 'link' && this.passwordLinkCode) { + post.passwordLinkCode = this.passwordLinkCode + } else if (this.passwordMode == 'input') { + post.password = this.user.password + post.password_confirmation = this.user.password_confirmation } - axios[method](location, this.user) + axios[method](location, post) .then(response => { if (response.data.statusInfo) { this.$store.state.authInfo.statusInfo = response.data.statusInfo diff --git a/src/resources/vue/User/Profile.vue b/src/resources/vue/User/Profile.vue --- a/src/resources/vue/User/Profile.vue +++ b/src/resources/vue/User/Profile.vue @@ -68,13 +68,14 @@
- -
-
-
- -
- + +
diff --git a/src/routes/api.php b/src/routes/api.php --- a/src/routes/api.php +++ b/src/routes/api.php @@ -100,6 +100,9 @@ Route::get('wallets/{id}/receipts', 'API\V4\WalletsController@receipts'); Route::get('wallets/{id}/receipts/{receipt}', 'API\V4\WalletsController@receiptDownload'); + Route::post('password-reset/code', 'API\PasswordResetController@codeCreate'); + Route::delete('password-reset/code/{id}', 'API\PasswordResetController@codeDelete'); + Route::post('payments', 'API\V4\PaymentsController@store'); //Route::delete('payments', 'API\V4\PaymentsController@cancel'); Route::get('payments/mandate', 'API\V4\PaymentsController@mandate'); diff --git a/src/tests/Browser/UserProfileTest.php b/src/tests/Browser/UserProfileTest.php --- a/src/tests/Browser/UserProfileTest.php +++ b/src/tests/Browser/UserProfileTest.php @@ -91,9 +91,10 @@ ->assertSeeIn('div.row:nth-child(8) label', 'Country') ->assertValue('div.row:nth-child(8) select', $this->profile['country']) ->assertSeeIn('div.row:nth-child(9) label', 'Password') - ->assertValue('div.row:nth-child(9) input[type=password]', '') - ->assertSeeIn('div.row:nth-child(10) label', 'Confirm Password') - ->assertValue('div.row:nth-child(10) input[type=password]', '') + ->assertValue('div.row:nth-child(9) input#password', '') + ->assertValue('div.row:nth-child(9) input#password_confirmation', '') + ->assertAttribute('#password', 'placeholder', 'Password') + ->assertAttribute('#password_confirmation', 'placeholder', 'Confirm Password') ->assertSeeIn('button[type=submit]', 'Submit'); // Test form error handling diff --git a/src/tests/Browser/UsersTest.php b/src/tests/Browser/UsersTest.php --- a/src/tests/Browser/UsersTest.php +++ b/src/tests/Browser/UsersTest.php @@ -93,10 +93,13 @@ public function testInfo(): void { $this->browse(function (Browser $browser) { - $user = User::where('email', 'john@kolab.org')->first(); + $john = $this->getTestUser('john@kolab.org'); + $jack = $this->getTestUser('jack@kolab.org'); + $john->verificationcodes()->delete(); + $jack->verificationcodes()->delete(); // Test that the page requires authentication - $browser->visit('/user/' . $user->id) + $browser->visit('/user/' . $john->id) ->on(new Home()) ->submitLogon('john@kolab.org', 'simple123', false) ->on(new UserInfo()) @@ -122,9 +125,12 @@ ->assertValue('@input', ''); }) ->assertSeeIn('div.row:nth-child(7) label', 'Password') - ->assertValue('div.row:nth-child(7) input[type=password]', '') - ->assertSeeIn('div.row:nth-child(8) label', 'Confirm Password') - ->assertValue('div.row:nth-child(8) input[type=password]', '') + ->assertValue('div.row:nth-child(7) input#password', '') + ->assertValue('div.row:nth-child(7) input#password_confirmation', '') + ->assertAttribute('#password', 'placeholder', 'Password') + ->assertAttribute('#password_confirmation', 'placeholder', 'Confirm Password') + ->assertMissing('div.row:nth-child(7) .btn-group') + ->assertMissing('div.row:nth-child(7) #password-link') ->assertSeeIn('button[type=submit]', 'Submit') // Clear some fields and submit ->vueClear('#first_name') @@ -141,8 +147,11 @@ $browser->type('#password', 'aaaaaa') ->vueClear('#password_confirmation') ->click('button[type=submit]') - ->waitFor('#password + .invalid-feedback') - ->assertSeeIn('#password + .invalid-feedback', 'The password confirmation does not match.') + ->waitFor('#password_confirmation + .invalid-feedback') + ->assertSeeIn( + '#password_confirmation + .invalid-feedback', + 'The password confirmation does not match.' + ) ->assertFocused('#password') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); @@ -173,14 +182,13 @@ ->click('@table tr:nth-child(3) a') ->on(new UserInfo()); - $john = User::where('email', 'john@kolab.org')->first(); - $alias = UserAlias::where('user_id', $john->id)->where('alias', 'john.test@kolab.org')->first(); + $alias = $john->aliases()->where('alias', 'john.test@kolab.org')->first(); $this->assertTrue(!empty($alias)); // Test subscriptions $browser->with('@general', function (Browser $browser) { - $browser->assertSeeIn('div.row:nth-child(9) label', 'Subscriptions') - ->assertVisible('@skus.row:nth-child(9)') + $browser->assertSeeIn('div.row:nth-child(8) label', 'Subscriptions') + ->assertVisible('@skus.row:nth-child(8)') ->with('@skus', function ($browser) { $browser->assertElementsCount('tbody tr', 6) // Mailbox SKU @@ -253,7 +261,7 @@ $expected = ['activesync', 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage']; - $this->assertEntitlements($john, $expected); + $this->assertEntitlements($john->fresh(), $expected); // Test subscriptions interaction $browser->with('@general', function (Browser $browser) { @@ -285,6 +293,64 @@ ->assertNotReadonly('#sku-input-activesync'); }); }); + + // Test password reset link delete and create + $code = new \App\VerificationCode(['mode' => 'password-reset']); + $jack->verificationcodes()->save($code); + + $browser->visit('/user/' . $jack->id) + ->on(new UserInfo()) + ->with('@general', function (Browser $browser) use ($jack, $john, $code) { + // Test displaying an existing password reset link + $link = Browser::$baseUrl . '/password-reset/' . $code->code . '-' . $code->short_code; + $browser->assertSeeIn('div.row:nth-child(7) label', 'Password') + ->assertMissing('#password') + ->assertMissing('#password_confirmation') + ->assertMissing('#pass-mode-link:checked') + ->assertMissing('#pass-mode-input:checked') + ->assertSeeIn('#password-link code', $link) + ->assertVisible('#password-link button.text-danger') + ->assertVisible('#password-link button:not(.text-danger)') + ->assertAttribute('#password-link button:not(.text-danger)', 'title', 'Copy') + ->assertAttribute('#password-link button.text-danger', 'title', 'Delete'); + + // Test deleting an existing password reset link + $browser->click('#password-link button.text-danger') + ->assertToast(Toast::TYPE_SUCCESS, 'Password reset code deleted successfully.') + ->assertMissing('#password-link') + ->assertMissing('#pass-mode-link:checked') + ->assertMissing('#pass-mode-input:checked') + ->assertMissing('#password'); + + $this->assertSame(0, $jack->verificationcodes()->count()); + + // Test creating a password reset link + $link = preg_replace('|/[a-z0-9A-Z-]+$|', '', $link) . '/'; + $browser->click('#pass-mode-link + label') + ->assertMissing('#password') + ->assertMissing('#password_confirmation') + ->waitFor('#password-link code') + ->assertSeeIn('#password-link code', $link); + + // Test copy to clipboard + /* TODO: Figure out how to give permission to do this operation + $code = $john->verificationcodes()->first(); + $link .= $code->code . '-' . $code->short_code; + + $browser->assertMissing('#password-link button.text-danger') + ->click('#password-link button:not(.text-danger)') + ->keys('#organization', ['{left_control}', 'v']) + ->assertAttribute('#organization', 'value', $link) + ->vueClear('#organization'); + */ + + // Finally submit the form + $browser->click('button[type=submit]') + ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); + + $this->assertSame(1, $jack->verificationcodes()->where('active', true)->count()); + $this->assertSame(0, $john->verificationcodes()->count()); + }); }); } @@ -348,10 +414,15 @@ ->assertValue('@input', ''); }) ->assertSeeIn('div.row:nth-child(6) label', 'Password') - ->assertValue('div.row:nth-child(6) input[type=password]', '') - ->assertSeeIn('div.row:nth-child(7) label', 'Confirm Password') - ->assertValue('div.row:nth-child(7) input[type=password]', '') - ->assertSeeIn('div.row:nth-child(8) label', 'Package') + ->assertValue('div.row:nth-child(6) input#password', '') + ->assertValue('div.row:nth-child(6) input#password_confirmation', '') + ->assertAttribute('#password', 'placeholder', 'Password') + ->assertAttribute('#password_confirmation', 'placeholder', 'Confirm Password') + ->assertSeeIn('div.row:nth-child(6) .btn-group input:first-child + label', 'Enter password') + ->assertSeeIn('div.row:nth-child(6) .btn-group input:not(:first-child) + label', 'Set via link') + ->assertChecked('div.row:nth-child(6) .btn-group input:first-child') + ->assertMissing('div.row:nth-child(6) #password-link') + ->assertSeeIn('div.row:nth-child(7) label', 'Package') // assert packages list widget, select "Lite Account" ->with('@packages', function ($browser) { $browser->assertElementsCount('tbody tr', 2) @@ -371,16 +442,15 @@ $browser->click('button[type=submit]') ->assertFocused('#email') ->type('#email', 'invalid email') - ->click('button[type=submit]') - ->assertFocused('#password') ->type('#password', 'simple123') - ->click('button[type=submit]') - ->assertFocused('#password_confirmation') ->type('#password_confirmation', 'simple') ->click('button[type=submit]') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') ->assertSeeIn('#email + .invalid-feedback', 'The specified email is invalid.') - ->assertSeeIn('#password + .invalid-feedback', 'The password confirmation does not match.'); + ->assertSeeIn( + '#password_confirmation + .invalid-feedback', + 'The password confirmation does not match.' + ); }); // Test form error handling (aliases) diff --git a/src/tests/Feature/Controller/PasswordResetTest.php b/src/tests/Feature/Controller/PasswordResetTest.php --- a/src/tests/Feature/Controller/PasswordResetTest.php +++ b/src/tests/Feature/Controller/PasswordResetTest.php @@ -330,4 +330,82 @@ // TODO: Check if the access token works } + + /** + * Test creating a password verification code + * + * @return void + */ + public function testCodeCreate() + { + $user = $this->getTestUser('john@kolab.org'); + $user->verificationcodes()->delete(); + + $response = $this->actingAs($user)->post('/api/v4/password-reset/code', []); + $response->assertStatus(200); + + $json = $response->json(); + + $code = $user->verificationcodes()->first(); + + $this->assertSame('success', $json['status']); + $this->assertSame($code->code, $json['code']); + $this->assertSame($code->short_code, $json['short_code']); + $this->assertStringContainsString(now()->addHours(24)->toDateString(), $json['expires_at']); + } + + /** + * Test deleting a password verification code + * + * @return void + */ + public function testCodeDelete() + { + $user = $this->getTestUser('passwordresettest@' . \config('app.domain')); + $john = $this->getTestUser('john@kolab.org'); + $jack = $this->getTestUser('jack@kolab.org'); + $john->verificationcodes()->delete(); + $jack->verificationcodes()->delete(); + + $john_code = new VerificationCode(['mode' => 'password-reset']); + $john->verificationcodes()->save($john_code); + $jack_code = new VerificationCode(['mode' => 'password-reset']); + $jack->verificationcodes()->save($jack_code); + $user_code = new VerificationCode(['mode' => 'password-reset']); + $user->verificationcodes()->save($user_code); + + // Unauth access + $response = $this->delete('/api/v4/password-reset/code/' . $user_code->code); + $response->assertStatus(401); + + // Non-existing code + $response = $this->actingAs($john)->delete('/api/v4/password-reset/code/123'); + $response->assertStatus(404); + + // Existing code belonging to another user not controlled by the acting user + $response = $this->actingAs($john)->delete('/api/v4/password-reset/code/' . $user_code->code); + $response->assertStatus(403); + + // Deleting owned code + $response = $this->actingAs($john)->delete('/api/v4/password-reset/code/' . $john_code->code); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(0, $john->verificationcodes()->count()); + $this->assertSame('success', $json['status']); + $this->assertSame("Password reset code deleted successfully.", $json['message']); + + // Deleting code of another user owned by the acting user + // also use code+short-code as input parameter + $id = $jack_code->code . '-' . $jack_code->short_code; + $response = $this->actingAs($john)->delete('/api/v4/password-reset/code/' . $id); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(0, $jack->verificationcodes()->count()); + $this->assertSame('success', $json['status']); + $this->assertSame("Password reset code deleted successfully.", $json['message']); + } } diff --git a/src/tests/Feature/Controller/UsersTest.php b/src/tests/Feature/Controller/UsersTest.php --- a/src/tests/Feature/Controller/UsersTest.php +++ b/src/tests/Feature/Controller/UsersTest.php @@ -704,7 +704,7 @@ 'storage', 'storage', 'storage', 'storage', 'storage']); // Assert the wallet to which the new user should be assigned to $wallet = $user->wallet(); - $this->assertSame($john->wallets()->first()->id, $wallet->id); + $this->assertSame($john->wallets->first()->id, $wallet->id); // Attempt to create a user previously deleted $user->delete(); @@ -729,9 +729,41 @@ $this->assertEntitlements($user, ['groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage']); - // Test acting as account controller (not owner) + // Test password reset link "mode" + $code = new \App\VerificationCode(['mode' => 'password-reset', 'active' => false]); + $john->verificationcodes()->save($code); - $this->markTestIncomplete(); + $post = [ + 'first_name' => 'John2', + 'last_name' => 'Doe2', + 'email' => 'deleted@kolab.org', + 'organization' => '', + 'aliases' => [], + 'passwordLinkCode' => $code->code . '-' . $code->short_code, + 'package' => $package_kolab->id, + ]; + + $response = $this->actingAs($john)->post("/api/v4/users", $post); + $json = $response->json(); + + $response->assertStatus(200); + + $this->assertSame('success', $json['status']); + $this->assertSame("User created successfully.", $json['message']); + $this->assertCount(2, $json); + + $user = $this->getTestUser('deleted@kolab.org'); + $code->refresh(); + + $this->assertSame($user->id, $code->user_id); + $this->assertTrue($code->active); + $this->assertTrue(is_string($user->password) && strlen($user->password) >= 60); + + // Test acting as account controller not owner, which is not yet supported + $john->wallets->first()->addController($user); + + $response = $this->actingAs($user)->post("/api/v4/users", []); + $response->assertStatus(403); } /** @@ -928,6 +960,23 @@ $this->assertSame([0, 0, 0, 0, 0, 25], $storage_cost); $this->assertTrue(empty($json['statusInfo'])); + + // Test password reset link "mode" + $code = new \App\VerificationCode(['mode' => 'password-reset', 'active' => false]); + $owner->verificationcodes()->save($code); + + $post = ['passwordLinkCode' => $code->code . '-' . $code->short_code]; + + $response = $this->actingAs($owner)->put("/api/v4/users/{$user->id}", $post); + $json = $response->json(); + + $response->assertStatus(200); + + $code->refresh(); + + $this->assertSame($user->id, $code->user_id); + $this->assertTrue($code->active); + $this->assertSame($user->password, $user->fresh()->password); } /** diff --git a/src/tests/Feature/VerificationCodeTest.php b/src/tests/Feature/VerificationCodeTest.php --- a/src/tests/Feature/VerificationCodeTest.php +++ b/src/tests/Feature/VerificationCodeTest.php @@ -45,6 +45,7 @@ $this->assertFalse($code->isExpired()); $this->assertTrue(strlen($code->code) === VerificationCode::CODE_LENGTH); $this->assertTrue(strlen($code->short_code) === $code_length); + $this->assertTrue($code->active); $this->assertSame($data['mode'], $code->mode); $this->assertEquals($user->id, $code->user->id); $this->assertInstanceOf(\DateTime::class, $code->expires_at); @@ -54,5 +55,12 @@ $this->assertInstanceOf(VerificationCode::class, $inst); $this->assertSame($inst->code, $code->code); + + // Custom active flag and custom expires_at + $data['expires_at'] = Carbon::now()->addDays(10); + $data['active'] = false; + $code = VerificationCode::create($data); + $this->assertFalse($code->active); + $this->assertSame($code->expires_at->toDateTimeString(), $data['expires_at']->toDateTimeString()); } }