5 use BookStack\Access\Mfa\MfaValue;
6 use BookStack\Access\SocialAccount;
7 use BookStack\Access\UserInviteException;
8 use BookStack\Access\UserInviteService;
9 use BookStack\Activity\ActivityType;
10 use BookStack\Activity\Models\Activity;
11 use BookStack\Activity\Models\Comment;
12 use BookStack\Activity\Models\Favourite;
13 use BookStack\Activity\Models\View;
14 use BookStack\Activity\Models\Watch;
15 use BookStack\Api\ApiToken;
16 use BookStack\Entities\Models\Deletion;
17 use BookStack\Entities\Models\PageRevision;
18 use BookStack\Exports\Import;
19 use BookStack\Uploads\Attachment;
20 use BookStack\Uploads\Image;
21 use BookStack\Users\Models\Role;
22 use BookStack\Users\Models\User;
23 use Illuminate\Support\Facades\Hash;
24 use Illuminate\Support\Str;
25 use Mockery\MockInterface;
28 class UserManagementTest extends TestCase
30 public function test_user_creation()
32 /** @var User $user */
33 $user = User::factory()->make();
34 $adminRole = Role::getRole('admin');
36 $resp = $this->asAdmin()->get('/settings/users');
37 $this->withHtml($resp)->assertElementContains('a[href="' . url('/settings/users/create') . '"]', 'Add New User');
39 $resp = $this->get('/settings/users/create');
40 $this->withHtml($resp)->assertElementContains('form[action="' . url('/settings/users/create') . '"]', 'Save');
42 $resp = $this->post('/settings/users/create', [
43 'name' => $user->name,
44 'email' => $user->email,
45 'password' => $user->password,
46 'password-confirm' => $user->password,
47 'roles[' . $adminRole->id . ']' => 'true',
49 $resp->assertRedirect('/settings/users');
51 $resp = $this->get('/settings/users');
52 $resp->assertSee($user->name);
54 $this->assertDatabaseHas('users', $user->only('name', 'email'));
57 $this->assertStringStartsWith(Str::slug($user->name), $user->slug);
60 public function test_user_updating()
62 $user = $this->users->viewer();
63 $password = $user->password;
65 $resp = $this->asAdmin()->get('/settings/users/' . $user->id);
66 $resp->assertSee($user->email);
68 $this->put($user->getEditUrl(), [
69 'name' => 'Barry Scott',
70 ])->assertRedirect('/settings/users');
72 $this->assertDatabaseHas('users', ['id' => $user->id, 'name' => 'Barry Scott', 'password' => $password]);
73 $this->assertDatabaseMissing('users', ['name' => $user->name]);
76 $this->assertStringStartsWith(Str::slug($user->name), $user->slug);
79 public function test_user_password_update()
81 $user = $this->users->viewer();
82 $userProfilePage = '/settings/users/' . $user->id;
84 $this->asAdmin()->get($userProfilePage);
85 $this->put($userProfilePage, [
86 'password' => 'newpassword',
87 ])->assertRedirect($userProfilePage);
89 $this->get($userProfilePage)->assertSee('Password confirmation required');
91 $this->put($userProfilePage, [
92 'password' => 'newpassword',
93 'password-confirm' => 'newpassword',
94 ])->assertRedirect('/settings/users');
96 $userPassword = User::query()->find($user->id)->password;
97 $this->assertTrue(Hash::check('newpassword', $userPassword));
100 public function test_user_can_be_updated_with_single_char_name()
102 $user = $this->users->viewer();
103 $this->asAdmin()->put("/settings/users/{$user->id}", [
105 ])->assertRedirect('/settings/users');
107 $this->assertEquals('b', $user->refresh()->name);
110 public function test_user_cannot_be_deleted_if_last_admin()
112 $adminRole = Role::getRole('admin');
114 // Delete all but one admin user if there are more than one
115 $adminUsers = $adminRole->users;
116 if (count($adminUsers) > 1) {
117 /** @var User $user */
118 foreach ($adminUsers->splice(1) as $user) {
123 // Ensure we currently only have 1 admin user
124 $this->assertEquals(1, $adminRole->users()->count());
125 /** @var User $user */
126 $user = $adminRole->users->first();
128 $resp = $this->asAdmin()->delete('/settings/users/' . $user->id);
129 $resp->assertRedirect('/settings/users/' . $user->id);
131 $resp = $this->get('/settings/users/' . $user->id);
132 $resp->assertSee('You cannot delete the only admin');
134 $this->assertDatabaseHas('users', ['id' => $user->id]);
137 public function test_delete()
139 $editor = $this->users->editor();
140 $resp = $this->asAdmin()->delete("settings/users/{$editor->id}");
141 $resp->assertRedirect('/settings/users');
142 $resp = $this->followRedirects($resp);
144 $resp->assertSee('User successfully removed');
145 $this->assertActivityExists(ActivityType::USER_DELETE);
147 $this->assertDatabaseMissing('users', ['id' => $editor->id]);
150 public function test_delete_offers_migrate_option()
152 $editor = $this->users->editor();
153 $resp = $this->asAdmin()->get("settings/users/{$editor->id}/delete");
154 $resp->assertSee('Migrate Ownership');
155 $resp->assertSee('new_owner_id');
158 public function test_migrate_option_hidden_if_user_cannot_manage_users()
160 $editor = $this->users->editor();
162 $resp = $this->asEditor()->get("settings/users/{$editor->id}/delete");
163 $resp->assertDontSee('Migrate Ownership');
164 $resp->assertDontSee('new_owner_id');
166 $this->permissions->grantUserRolePermissions($editor, ['users-manage']);
168 $resp = $this->asEditor()->get("settings/users/{$editor->id}/delete");
169 $resp->assertSee('Migrate Ownership');
170 $this->withHtml($resp)->assertElementExists('form input[name="new_owner_id"]');
171 $resp->assertSee('new_owner_id');
174 public function test_delete_with_new_owner_id_changes_ownership()
176 $page = $this->entities->page();
177 $owner = $page->ownedBy;
178 $newOwner = User::query()->where('id', '!=', $owner->id)->first();
180 $this->asAdmin()->delete("settings/users/{$owner->id}", ['new_owner_id' => $newOwner->id])->assertRedirect();
181 $this->assertDatabaseHasEntityData('page', [
183 'owned_by' => $newOwner->id,
187 public function test_delete_with_empty_owner_migration_id_works()
189 $user = $this->users->editor();
191 $resp = $this->asAdmin()->delete("settings/users/{$user->id}", ['new_owner_id' => '']);
192 $resp->assertRedirect('/settings/users');
193 $this->assertActivityExists(ActivityType::USER_DELETE);
194 $this->assertSessionHas('success');
197 public function test_delete_with_empty_owner_migration_id_clears_relevant_id_uses()
199 $user = $this->users->editor();
200 $page = $this->entities->page();
201 $this->actingAs($user);
204 $activity = Activity::factory()->create(['user_id' => $user->id]);
205 $attachment = Attachment::factory()->create(['created_by' => $user->id, 'updated_by' => $user->id]);
206 $comment = Comment::factory()->create(['created_by' => $user->id, 'updated_by' => $user->id]);
207 $deletion = Deletion::factory()->create(['deleted_by' => $user->id]);
208 $page->forceFill(['owned_by' => $user->id, 'created_by' => $user->id, 'updated_by' => $user->id])->save();
209 $page->rebuildPermissions();
210 $image = Image::factory()->create(['created_by' => $user->id, 'updated_by' => $user->id]);
211 $import = Import::factory()->create(['created_by' => $user->id]);
212 $revision = PageRevision::factory()->create(['created_by' => $user->id]);
214 $apiToken = ApiToken::factory()->create(['user_id' => $user->id]);
215 \DB::table('email_confirmations')->insert(['user_id' => $user->id, 'token' => 'abc123']);
216 $favourite = Favourite::factory()->create(['user_id' => $user->id]);
217 $mfaValue = MfaValue::factory()->create(['user_id' => $user->id]);
218 $socialAccount = SocialAccount::factory()->create(['user_id' => $user->id]);
219 \DB::table('user_invites')->insert(['user_id' => $user->id, 'token' => 'abc123']);
220 View::incrementFor($page);
221 $watch = Watch::factory()->create(['user_id' => $user->id]);
223 $userColumnsByTable = [
224 'api_tokens' => ['user_id'],
225 'attachments' => ['created_by', 'updated_by'],
226 'comments' => ['created_by', 'updated_by'],
227 'deletions' => ['deleted_by'],
228 'email_confirmations' => ['user_id'],
229 'entities' => ['created_by', 'updated_by', 'owned_by'],
230 'favourites' => ['user_id'],
231 'images' => ['created_by', 'updated_by'],
232 'imports' => ['created_by'],
233 'joint_permissions' => ['owner_id'],
234 'mfa_values' => ['user_id'],
235 'page_revisions' => ['created_by'],
236 'role_user' => ['user_id'],
237 'social_accounts' => ['user_id'],
238 'user_invites' => ['user_id'],
239 'views' => ['user_id'],
240 'watches' => ['user_id'],
243 // Ensure columns have user id before deletion
244 foreach ($userColumnsByTable as $table => $columns) {
245 foreach ($columns as $column) {
246 $this->assertDatabaseHas($table, [$column => $user->id]);
250 $resp = $this->asAdmin()->delete("settings/users/{$user->id}", ['new_owner_id' => '']);
251 $resp->assertRedirect('/settings/users');
253 // Ensure columns missing user id after deletion
254 foreach ($userColumnsByTable as $table => $columns) {
255 foreach ($columns as $column) {
256 $this->assertDatabaseMissing($table, [$column => $user->id]);
260 // Check models exist where should be retained
261 $this->assertDatabaseHas('attachments', ['id' => $attachment->id, 'created_by' => null, 'updated_by' => null]);
262 $this->assertDatabaseHas('comments', ['id' => $comment->id, 'created_by' => null, 'updated_by' => null]);
263 $this->assertDatabaseHas('deletions', ['id' => $deletion->id, 'deleted_by' => null]);
264 $this->assertDatabaseHas('entities', ['id' => $page->id, 'created_by' => null, 'updated_by' => null, 'owned_by' => null]);
265 $this->assertDatabaseHas('images', ['id' => $image->id, 'created_by' => null, 'updated_by' => null]);
266 $this->assertDatabaseHas('imports', ['id' => $import->id, 'created_by' => null]);
267 $this->assertDatabaseHas('page_revisions', ['id' => $revision->id, 'created_by' => null]);
269 // Check models no longer exist where should have been deleted with the user
270 $this->assertDatabaseMissing('api_tokens', ['id' => $apiToken->id]);
271 $this->assertDatabaseMissing('email_confirmations', ['token' => 'abc123']);
272 $this->assertDatabaseMissing('favourites', ['id' => $favourite->id]);
273 $this->assertDatabaseMissing('mfa_values', ['id' => $mfaValue->id]);
274 $this->assertDatabaseMissing('social_accounts', ['id' => $socialAccount->id]);
275 $this->assertDatabaseMissing('user_invites', ['token' => 'abc123']);
276 $this->assertDatabaseMissing('watches', ['id' => $watch->id]);
278 // Ensure activity remains using the old ID (Special case for auditing changes)
279 $this->assertDatabaseHas('activities', ['id' => $activity->id, 'user_id' => $user->id]);
282 public function test_delete_removes_user_preferences()
284 $editor = $this->users->editor();
285 setting()->putUser($editor, 'dark-mode-enabled', 'true');
287 $this->assertDatabaseHas('settings', [
288 'setting_key' => 'user:' . $editor->id . ':dark-mode-enabled',
292 $this->asAdmin()->delete("settings/users/{$editor->id}");
294 $this->assertDatabaseMissing('settings', [
295 'setting_key' => 'user:' . $editor->id . ':dark-mode-enabled',
299 public function test_guest_profile_shows_limited_form()
301 $guest = $this->users->guest();
303 $resp = $this->asAdmin()->get('/settings/users/' . $guest->id);
304 $resp->assertSee('Guest');
305 $html = $this->withHtml($resp);
307 $html->assertElementNotExists('#password');
308 $html->assertElementNotExists('[name="language"]');
311 public function test_guest_profile_cannot_be_deleted()
313 $guestUser = $this->users->guest();
314 $resp = $this->asAdmin()->get('/settings/users/' . $guestUser->id . '/delete');
315 $resp->assertSee('Delete User');
316 $resp->assertSee('Guest');
317 $this->withHtml($resp)->assertElementContains('form[action$="/settings/users/' . $guestUser->id . '"] button', 'Confirm');
319 $resp = $this->delete('/settings/users/' . $guestUser->id);
320 $resp->assertRedirect('/settings/users/' . $guestUser->id);
321 $resp = $this->followRedirects($resp);
322 $resp->assertSee('cannot delete the guest user');
325 public function test_user_create_language_reflects_default_system_locale()
327 $langs = ['en', 'fr', 'hr'];
328 foreach ($langs as $lang) {
329 config()->set('app.default_locale', $lang);
330 $resp = $this->asAdmin()->get('/settings/users/create');
331 $this->withHtml($resp)->assertElementExists('select[name="language"] option[value="' . $lang . '"][selected]');
335 public function test_user_creation_is_not_performed_if_the_invitation_sending_fails()
337 /** @var User $user */
338 $user = User::factory()->make();
339 $adminRole = Role::getRole('admin');
341 // Simulate an invitation sending failure
342 $this->mock(UserInviteService::class, function (MockInterface $mock) {
343 $mock->shouldReceive('sendInvitation')->once()->andThrow(UserInviteException::class);
346 $this->asAdmin()->post('/settings/users/create', [
347 'name' => $user->name,
348 'email' => $user->email,
349 'send_invite' => 'true',
350 'roles[' . $adminRole->id . ']' => 'true',
353 // Since the invitation failed, the user should not exist in the database
354 $this->assertDatabaseMissing('users', $user->only('name', 'email'));
357 public function test_user_create_activity_is_not_persisted_if_the_invitation_sending_fails()
359 /** @var User $user */
360 $user = User::factory()->make();
362 $this->mock(UserInviteService::class, function (MockInterface $mock) {
363 $mock->shouldReceive('sendInvitation')->once()->andThrow(UserInviteException::class);
366 $this->asAdmin()->post('/settings/users/create', [
367 'name' => $user->name,
368 'email' => $user->email,
369 'send_invite' => 'true',
372 $this->assertDatabaseMissing('activities', ['type' => 'USER_CREATE']);
375 public function test_return_to_form_with_warning_if_the_invitation_sending_fails()
377 $logger = $this->withTestLogger();
378 /** @var User $user */
379 $user = User::factory()->make();
381 $this->mock(UserInviteService::class, function (MockInterface $mock) {
382 $mock->shouldReceive('sendInvitation')->once()->andThrow(UserInviteException::class);
385 $resp = $this->asAdmin()->post('/settings/users/create', [
386 'name' => $user->name,
387 'email' => $user->email,
388 'send_invite' => 'true',
391 $resp->assertRedirect('/settings/users/create');
392 $this->assertSessionError('Could not create user since invite email failed to send');
393 $this->assertEquals($user->email, session()->getOldInput('email'));
394 $this->assertTrue($logger->hasErrorThatContains('Failed to send user invite with error:'));
397 public function test_user_create_update_fails_if_locale_is_invalid()
399 $user = $this->users->editor();
402 $resp = $this->asAdmin()->put($user->getEditUrl(), ['language' => 'this_is_too_long']);
403 $resp->assertSessionHasErrors(['language' => 'The language may not be greater than 15 characters.']);
406 // Invalid characters
407 $resp = $this->put($user->getEditUrl(), ['language' => 'en<GB']);
408 $resp->assertSessionHasErrors(['language' => 'The language may only contain letters, numbers, dashes and underscores.']);
412 $resp = $this->post('/settings/users/create', [
413 'language' => 'en<GB_and_this_is_longer',
415 'email' => 'jimmy@example.com',
417 $resp->assertSessionHasErrors(['language' => 'The language may not be greater than 15 characters.']);
418 $resp->assertSessionHasErrors(['language' => 'The language may only contain letters, numbers, dashes and underscores.']);
421 public function test_user_avatar_update_and_reset()
423 $user = $this->users->viewer();
424 $avatarFile = $this->files->uploadedImage('avatar-icon.png');
426 $this->assertEquals(0, $user->image_id);
428 $upload = $this->asAdmin()->call('PUT', "/settings/users/{$user->id}", [
429 'name' => 'Barry Scott',
430 ], [], ['profile_image' => $avatarFile], []);
431 $upload->assertRedirect('/settings/users');
434 $this->assertNotEquals(0, $user->image_id);
435 /** @var Image $image */
436 $image = Image::query()->findOrFail($user->image_id);
437 $this->assertFileExists(public_path($image->path));
439 $reset = $this->put("/settings/users/{$user->id}", [
440 'name' => 'Barry Scott',
441 'profile_image_reset' => 'true',
443 $upload->assertRedirect('/settings/users');
446 $this->assertFileDoesNotExist(public_path($image->path));
447 $this->assertEquals(0, $user->image_id);