From 20a4d8949dcb8c689e1f5701d78977d110576584 Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Tue, 15 Aug 2023 08:42:06 -0700 Subject: [PATCH 01/10] Reset to -dev version string --- src-ui/src/environments/environment.prod.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-ui/src/environments/environment.prod.ts b/src-ui/src/environments/environment.prod.ts index a36d733c3..3d1d968bf 100644 --- a/src-ui/src/environments/environment.prod.ts +++ b/src-ui/src/environments/environment.prod.ts @@ -5,7 +5,7 @@ export const environment = { apiBaseUrl: document.baseURI + 'api/', apiVersion: '3', appTitle: 'Paperless-ngx', - version: '1.17.1', + version: '1.17.1-dev', webSocketHost: window.location.host, webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:', webSocketBaseUrl: base_url.pathname + 'ws/', From a0005c8b3e5275b119017700ef5de208bcb819cd Mon Sep 17 00:00:00 2001 From: amo13 Date: Wed, 16 Aug 2023 02:05:01 +0200 Subject: [PATCH 02/10] Enhancement: Allow to set a prefix for keys and channels in redis (#3993) --- docs/configuration.md | 6 ++++++ src/paperless/settings.py | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/docs/configuration.md b/docs/configuration.md index 0ed2218a6..c38221e50 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -35,6 +35,12 @@ matcher. Defaults to `redis://localhost:6379`. +`PAPERLESS_REDIS_PREFIX=` + +: Prefix to be used in Redis for keys and channels. Useful for sharing one Redis server among multiple Paperless instances. + + Defaults to no prefix. + ### Database `PAPERLESS_DBENGINE=` diff --git a/src/paperless/settings.py b/src/paperless/settings.py index b33d7fb7d..7d2dda0d9 100644 --- a/src/paperless/settings.py +++ b/src/paperless/settings.py @@ -364,6 +364,7 @@ CHANNEL_LAYERS = { "hosts": [_CHANNELS_REDIS_URL], "capacity": 2000, # default 100 "expiry": 15, # default 60 + "prefix": os.getenv("PAPERLESS_REDIS_PREFIX", ""), }, }, } @@ -679,6 +680,9 @@ CELERY_TASK_SEND_SENT_EVENT = True CELERY_SEND_TASK_SENT_EVENT = True CELERY_BROKER_CONNECTION_RETRY = True CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True +CELERY_BROKER_TRANSPORT_OPTIONS = { + "global_keyprefix": os.getenv("PAPERLESS_REDIS_PREFIX", ""), +} CELERY_TASK_TRACK_STARTED = True CELERY_TASK_TIME_LIMIT: Final[int] = __get_int("PAPERLESS_WORKER_TIMEOUT", 1800) From 03d93a7d6e25b4ddb12d69fe7bf88a9012a1f685 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Wed, 16 Aug 2023 23:49:42 -0700 Subject: [PATCH 03/10] Fix: enforce permissions on bulk_edit operations --- src/documents/tests/test_api.py | 104 ++++++++++++++++++++++++++++++++ src/documents/views.py | 18 +++++- 2 files changed, 121 insertions(+), 1 deletion(-) diff --git a/src/documents/tests/test_api.py b/src/documents/tests/test_api.py index d788cf6a4..88180d4d8 100644 --- a/src/documents/tests/test_api.py +++ b/src/documents/tests/test_api.py @@ -3453,6 +3453,110 @@ class TestBulkEdit(DirectoriesMixin, APITestCase): self.assertCountEqual(args[0], [self.doc2.id, self.doc3.id]) self.assertEqual(len(kwargs["set_permissions"]["view"]["users"]), 2) + @mock.patch("documents.serialisers.bulk_edit.set_permissions") + def test_insufficient_permissions_ownership(self, m): + """ + GIVEN: + - Documents owned by user other than logged in user + WHEN: + - set_permissions bulk edit API endpoint is called + THEN: + - User is not able to change permissions + """ + m.return_value = "OK" + self.doc1.owner = User.objects.get(username="temp_admin") + self.doc1.save() + user1 = User.objects.create(username="user1") + self.client.force_authenticate(user=user1) + + permissions = { + "owner": user1.id, + } + + response = self.client.post( + "/api/documents/bulk_edit/", + json.dumps( + { + "documents": [self.doc1.id, self.doc2.id, self.doc3.id], + "method": "set_permissions", + "parameters": {"set_permissions": permissions}, + }, + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + m.assert_not_called() + self.assertEqual(response.content, b"Insufficient permissions") + + response = self.client.post( + "/api/documents/bulk_edit/", + json.dumps( + { + "documents": [self.doc2.id, self.doc3.id], + "method": "set_permissions", + "parameters": {"set_permissions": permissions}, + }, + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + m.assert_called_once() + + @mock.patch("documents.serialisers.bulk_edit.set_storage_path") + def test_insufficient_permissions_edit(self, m): + """ + GIVEN: + - Documents for which current user only has view permissions + WHEN: + - API is called + THEN: + - set_storage_path is only called if user can edit all docs + """ + m.return_value = "OK" + self.doc1.owner = User.objects.get(username="temp_admin") + self.doc1.save() + user1 = User.objects.create(username="user1") + assign_perm("view_document", user1, self.doc1) + self.client.force_authenticate(user=user1) + + response = self.client.post( + "/api/documents/bulk_edit/", + json.dumps( + { + "documents": [self.doc1.id, self.doc2.id, self.doc3.id], + "method": "set_storage_path", + "parameters": {"storage_path": self.sp1.id}, + }, + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + m.assert_not_called() + self.assertEqual(response.content, b"Insufficient permissions") + + assign_perm("change_document", user1, self.doc1) + + response = self.client.post( + "/api/documents/bulk_edit/", + json.dumps( + { + "documents": [self.doc1.id, self.doc2.id, self.doc3.id], + "method": "set_storage_path", + "parameters": {"storage_path": self.sp1.id}, + }, + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + m.assert_called_once() + class TestBulkDownload(DirectoriesMixin, APITestCase): ENDPOINT = "/api/documents/bulk_download/" diff --git a/src/documents/views.py b/src/documents/views.py index d57ad4eea..b04b87243 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -54,6 +54,7 @@ from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ReadOnlyModelViewSet from rest_framework.viewsets import ViewSet +from documents import bulk_edit from documents.filters import ObjectOwnedOrGrantedPermissionsFilter from documents.permissions import PaperlessAdminPermissions from documents.permissions import PaperlessObjectPermissions @@ -694,7 +695,7 @@ class SavedViewViewSet(ModelViewSet, PassUserMixin): serializer.save(owner=self.request.user) -class BulkEditView(GenericAPIView): +class BulkEditView(GenericAPIView, PassUserMixin): permission_classes = (IsAuthenticated,) serializer_class = BulkEditSerializer parser_classes = (parsers.JSONParser,) @@ -703,10 +704,25 @@ class BulkEditView(GenericAPIView): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) + user = self.request.user method = serializer.validated_data.get("method") parameters = serializer.validated_data.get("parameters") documents = serializer.validated_data.get("documents") + if not user.is_superuser: + document_objs = Document.objects.filter(pk__in=documents) + has_perms = ( + all((doc.owner == user or doc.owner is None) for doc in document_objs) + if method == bulk_edit.set_permissions + else all( + has_perms_owner_aware(user, "change_document", doc) + for doc in document_objs + ) + ) + + if not has_perms: + return HttpResponseForbidden("Insufficient permissions") + try: # TODO: parameter validation result = method(documents, **parameters) From 06c63ef4a4445fd967d68be1251236d9cbe99f12 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Thu, 17 Aug 2023 20:09:40 -0700 Subject: [PATCH 04/10] Disable / hide some UI buttons / elements if insufficient permissions --- src-ui/messages.xlf | 112 +++++++----- .../manage/settings/settings.component.html | 26 +-- .../settings/settings.component.spec.ts | 160 ++++++++++++++---- .../manage/settings/settings.component.ts | 74 ++++++-- src-ui/src/app/services/toast.service.ts | 2 +- src-ui/src/theme.scss | 3 + 6 files changed, 272 insertions(+), 105 deletions(-) diff --git a/src-ui/messages.xlf b/src-ui/messages.xlf index 865591166..1d07e98e4 100644 --- a/src-ui/messages.xlf +++ b/src-ui/messages.xlf @@ -723,7 +723,7 @@ src/app/components/manage/settings/settings.component.ts - 600 + 648 @@ -2913,19 +2913,19 @@ src/app/components/manage/settings/settings.component.ts - 711 + 759 src/app/components/manage/settings/settings.component.ts - 771 + 819 src/app/components/manage/settings/settings.component.ts - 838 + 886 src/app/components/manage/settings/settings.component.ts - 901 + 949 @@ -2940,19 +2940,19 @@ src/app/components/manage/settings/settings.component.ts - 713 + 761 src/app/components/manage/settings/settings.component.ts - 773 + 821 src/app/components/manage/settings/settings.component.ts - 840 + 888 src/app/components/manage/settings/settings.component.ts - 903 + 951 @@ -4489,235 +4489,263 @@ 372 + + Error retrieving groups + + src/app/components/manage/settings/settings.component.ts + 278 + + + + Error retrieving users + + src/app/components/manage/settings/settings.component.ts + 287 + + + + Error retrieving mail rules + + src/app/components/manage/settings/settings.component.ts + 314 + + + + Error retrieving mail accounts + + src/app/components/manage/settings/settings.component.ts + 323 + + Saved view "" deleted. src/app/components/manage/settings/settings.component.ts - 482 + 530 Settings saved src/app/components/manage/settings/settings.component.ts - 584 + 632 Settings were saved successfully. src/app/components/manage/settings/settings.component.ts - 585 + 633 Settings were saved successfully. Reload is required to apply some changes. src/app/components/manage/settings/settings.component.ts - 589 + 637 Reload now src/app/components/manage/settings/settings.component.ts - 590 + 638 Use system language src/app/components/manage/settings/settings.component.ts - 609 + 657 Use date format of display language src/app/components/manage/settings/settings.component.ts - 616 + 664 Error while storing settings on server. src/app/components/manage/settings/settings.component.ts - 636 + 684 Password has been changed, you will be logged out momentarily. src/app/components/manage/settings/settings.component.ts - 679 + 727 Saved user "". src/app/components/manage/settings/settings.component.ts - 686 + 734 Error saving user. src/app/components/manage/settings/settings.component.ts - 698 + 746 Confirm delete user account src/app/components/manage/settings/settings.component.ts - 709 + 757 This operation will permanently delete this user account. src/app/components/manage/settings/settings.component.ts - 710 + 758 Deleted user src/app/components/manage/settings/settings.component.ts - 719 + 767 Error deleting user. src/app/components/manage/settings/settings.component.ts - 727 + 775 Saved group "". src/app/components/manage/settings/settings.component.ts - 748 + 796 Error saving group. src/app/components/manage/settings/settings.component.ts - 758 + 806 Confirm delete user group src/app/components/manage/settings/settings.component.ts - 769 + 817 This operation will permanently delete this user group. src/app/components/manage/settings/settings.component.ts - 770 + 818 Deleted group src/app/components/manage/settings/settings.component.ts - 779 + 827 Error deleting group. src/app/components/manage/settings/settings.component.ts - 787 + 835 Saved account "". src/app/components/manage/settings/settings.component.ts - 813 + 861 Error saving account. src/app/components/manage/settings/settings.component.ts - 825 + 873 Confirm delete mail account src/app/components/manage/settings/settings.component.ts - 836 + 884 This operation will permanently delete this mail account. src/app/components/manage/settings/settings.component.ts - 837 + 885 Deleted mail account src/app/components/manage/settings/settings.component.ts - 846 + 894 Error deleting mail account. src/app/components/manage/settings/settings.component.ts - 855 + 903 Saved rule "". src/app/components/manage/settings/settings.component.ts - 876 + 924 Error saving rule. src/app/components/manage/settings/settings.component.ts - 888 + 936 Confirm delete mail rule src/app/components/manage/settings/settings.component.ts - 899 + 947 This operation will permanently delete this mail rule. src/app/components/manage/settings/settings.component.ts - 900 + 948 Deleted mail rule src/app/components/manage/settings/settings.component.ts - 909 + 957 Error deleting mail rule. src/app/components/manage/settings/settings.component.ts - 918 + 966 diff --git a/src-ui/src/app/components/manage/settings/settings.component.html b/src-ui/src/app/components/manage/settings/settings.component.html index 5090d531d..8b0132902 100644 --- a/src-ui/src/app/components/manage/settings/settings.component.html +++ b/src-ui/src/app/components/manage/settings/settings.component.html @@ -243,7 +243,7 @@

Mail accounts - +
{{account.imap_server}}
@@ -280,7 +280,7 @@

Mail rules -

+
{{(mailAccountService.getCached(rule.account) | async)?.name}}
@@ -323,7 +323,7 @@ -
  • +
  • Users & Groups @@ -334,7 +334,7 @@ - Add User + Add User
    • @@ -350,13 +350,13 @@
    • -
      +
      {{user.first_name}} {{user.last_name}}
      {{user.groups?.map(getGroupName, this).join(', ')}}
      - - + +
      @@ -369,7 +369,7 @@ - Add Group + Add Group
        @@ -385,13 +385,13 @@
      • -
        +
        - - + +
        diff --git a/src-ui/src/app/components/manage/settings/settings.component.spec.ts b/src-ui/src/app/components/manage/settings/settings.component.spec.ts index c4a9d4a4b..3919e6499 100644 --- a/src-ui/src/app/components/manage/settings/settings.component.spec.ts +++ b/src-ui/src/app/components/manage/settings/settings.component.spec.ts @@ -116,52 +116,66 @@ describe('SettingsComponent', () => { jest .spyOn(permissionsService, 'currentUserOwnsObject') .mockReturnValue(true) - jest.spyOn(userService, 'listAll').mockReturnValue( - of({ - all: users.map((u) => u.id), - count: users.length, - results: users.concat([]), - }) - ) groupService = TestBed.inject(GroupService) - jest.spyOn(groupService, 'listAll').mockReturnValue( - of({ - all: groups.map((g) => g.id), - count: groups.length, - results: groups.concat([]), - }) - ) savedViewService = TestBed.inject(SavedViewService) - jest.spyOn(savedViewService, 'listAll').mockReturnValue( - of({ - all: savedViews.map((v) => v.id), - count: savedViews.length, - results: (savedViews as PaperlessSavedView[]).concat([]), - }) - ) mailAccountService = TestBed.inject(MailAccountService) - jest.spyOn(mailAccountService, 'listAll').mockReturnValue( - of({ - all: mailAccounts.map((a) => a.id), - count: mailAccounts.length, - results: (mailAccounts as PaperlessMailAccount[]).concat([]), - }) - ) mailRuleService = TestBed.inject(MailRuleService) - jest.spyOn(mailRuleService, 'listAll').mockReturnValue( - of({ - all: mailRules.map((r) => r.id), - count: mailRules.length, - results: (mailRules as PaperlessMailRule[]).concat([]), - }) - ) + }) + + function completeSetup(excludeService = null) { + if (excludeService !== userService) { + jest.spyOn(userService, 'listAll').mockReturnValue( + of({ + all: users.map((u) => u.id), + count: users.length, + results: users.concat([]), + }) + ) + } + if (excludeService !== groupService) { + jest.spyOn(groupService, 'listAll').mockReturnValue( + of({ + all: groups.map((g) => g.id), + count: groups.length, + results: groups.concat([]), + }) + ) + } + if (excludeService !== savedViewService) { + jest.spyOn(savedViewService, 'listAll').mockReturnValue( + of({ + all: savedViews.map((v) => v.id), + count: savedViews.length, + results: (savedViews as PaperlessSavedView[]).concat([]), + }) + ) + } + if (excludeService !== mailAccountService) { + jest.spyOn(mailAccountService, 'listAll').mockReturnValue( + of({ + all: mailAccounts.map((a) => a.id), + count: mailAccounts.length, + results: (mailAccounts as PaperlessMailAccount[]).concat([]), + }) + ) + } + if (excludeService !== mailRuleService) { + jest.spyOn(mailRuleService, 'listAll').mockReturnValue( + of({ + all: mailRules.map((r) => r.id), + count: mailRules.length, + results: (mailRules as PaperlessMailRule[]).concat([]), + }) + ) + } fixture = TestBed.createComponent(SettingsComponent) component = fixture.componentInstance fixture.detectChanges() - }) + } it('should support tabbed settings & change URL, prevent navigation if dirty confirmation rejected', () => { + completeSetup() const navigateSpy = jest.spyOn(router, 'navigate') const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink)) tabButtons[1].nativeElement.dispatchEvent(new MouseEvent('click')) @@ -187,6 +201,7 @@ describe('SettingsComponent', () => { }) it('should support direct link to tab by URL, scroll if needed', () => { + completeSetup() jest .spyOn(activatedRoute, 'paramMap', 'get') .mockReturnValue(of(convertToParamMap({ section: 'mail' }))) @@ -199,6 +214,7 @@ describe('SettingsComponent', () => { }) it('should lazy load tab data', () => { + completeSetup() const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink)) expect(component.savedViews).toBeUndefined() @@ -221,6 +237,7 @@ describe('SettingsComponent', () => { }) it('should support save saved views, show error', () => { + completeSetup() component.maybeInitializeTab(3) // SavedViews const toastErrorSpy = jest.spyOn(toastService, 'showError') @@ -248,6 +265,7 @@ describe('SettingsComponent', () => { }) it('should support save local settings updating appearance settings and calling API, show error', () => { + completeSetup() const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastSpy = jest.spyOn(toastService, 'show') const storeSpy = jest.spyOn(settingsService, 'storeSettings') @@ -275,6 +293,7 @@ describe('SettingsComponent', () => { }) it('should offer reload if settings changes require', () => { + completeSetup() let toast: Toast toastService.getToasts().subscribe((t) => (toast = t[0])) component.initialize(true) // reset @@ -288,6 +307,7 @@ describe('SettingsComponent', () => { }) it('should allow setting theme color, visually apply change immediately but not save', () => { + completeSetup() const appearanceSpy = jest.spyOn( settingsService, 'updateAppearanceSettings' @@ -304,6 +324,7 @@ describe('SettingsComponent', () => { }) it('should support delete saved view', () => { + completeSetup() component.maybeInitializeTab(3) // SavedViews const toastSpy = jest.spyOn(toastService, 'showInfo') const deleteSpy = jest.spyOn(savedViewService, 'delete') @@ -316,6 +337,7 @@ describe('SettingsComponent', () => { }) it('should support edit / create user, show error if needed', () => { + completeSetup() let modal: NgbModalRef modalService.activeInstances.subscribe((refs) => (modal = refs[0])) component.editUser(users[0]) @@ -332,6 +354,7 @@ describe('SettingsComponent', () => { }) it('should support delete user, show error if needed', () => { + completeSetup() let modal: NgbModalRef modalService.activeInstances.subscribe((refs) => (modal = refs[0])) component.deleteUser(users[0]) @@ -352,6 +375,7 @@ describe('SettingsComponent', () => { }) it('should logout current user if password changed, after delay', fakeAsync(() => { + completeSetup() let modal: NgbModalRef modalService.activeInstances.subscribe((refs) => (modal = refs[0])) component.editUser(users[0]) @@ -371,6 +395,7 @@ describe('SettingsComponent', () => { })) it('should support edit / create group, show error if needed', () => { + completeSetup() let modal: NgbModalRef modalService.activeInstances.subscribe((refs) => (modal = refs[0])) component.editGroup(groups[0]) @@ -386,6 +411,7 @@ describe('SettingsComponent', () => { }) it('should support delete group, show error if needed', () => { + completeSetup() let modal: NgbModalRef modalService.activeInstances.subscribe((refs) => (modal = refs[0])) component.deleteGroup(users[0]) @@ -406,12 +432,71 @@ describe('SettingsComponent', () => { }) it('should get group name', () => { + completeSetup() component.maybeInitializeTab(5) // UsersGroups expect(component.getGroupName(1)).toEqual(groups[0].name) expect(component.getGroupName(11)).toEqual('') }) + it('should show errors on load if load mailAccounts failure', () => { + const toastErrorSpy = jest.spyOn(toastService, 'showError') + jest + .spyOn(mailAccountService, 'listAll') + .mockImplementation(() => + throwError(() => new Error('failed to load mail accounts')) + ) + completeSetup(mailAccountService) + const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink)) + tabButtons[3].nativeElement.dispatchEvent(new MouseEvent('click')) // mail tab + fixture.detectChanges() + expect(toastErrorSpy).toBeCalled() + }) + + it('should show errors on load if load mailRules failure', () => { + const toastErrorSpy = jest.spyOn(toastService, 'showError') + jest + .spyOn(mailRuleService, 'listAll') + .mockImplementation(() => + throwError(() => new Error('failed to load mail rules')) + ) + completeSetup(mailRuleService) + const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink)) + tabButtons[3].nativeElement.dispatchEvent(new MouseEvent('click')) // mail tab + fixture.detectChanges() + // tabButtons[4].nativeElement.dispatchEvent(new MouseEvent('click')) + expect(toastErrorSpy).toBeCalled() + }) + + it('should show errors on load if load users failure', () => { + const toastErrorSpy = jest.spyOn(toastService, 'showError') + jest + .spyOn(userService, 'listAll') + .mockImplementation(() => + throwError(() => new Error('failed to load users')) + ) + completeSetup(userService) + const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink)) + tabButtons[4].nativeElement.dispatchEvent(new MouseEvent('click')) // users tab + fixture.detectChanges() + expect(toastErrorSpy).toBeCalled() + }) + + it('should show errors on load if load groups failure', () => { + const toastErrorSpy = jest.spyOn(toastService, 'showError') + jest + .spyOn(groupService, 'listAll') + .mockImplementation(() => + throwError(() => new Error('failed to load groups')) + ) + completeSetup(groupService) + const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink)) + tabButtons[4].nativeElement.dispatchEvent(new MouseEvent('click')) // users tab + fixture.detectChanges() + expect(toastErrorSpy).toBeCalled() + }) + it('should support edit / create mail account, show error if needed', () => { + completeSetup() let modal: NgbModalRef modalService.activeInstances.subscribe((refs) => (modal = refs[0])) component.editMailAccount(mailAccounts[0] as PaperlessMailAccount) @@ -427,6 +512,7 @@ describe('SettingsComponent', () => { }) it('should support delete mail account, show error if needed', () => { + completeSetup() let modal: NgbModalRef modalService.activeInstances.subscribe((refs) => (modal = refs[0])) component.deleteMailAccount(mailAccounts[0] as PaperlessMailAccount) @@ -447,6 +533,7 @@ describe('SettingsComponent', () => { }) it('should support edit / create mail rule, show error if needed', () => { + completeSetup() let modal: NgbModalRef modalService.activeInstances.subscribe((refs) => (modal = refs[0])) component.editMailRule(mailRules[0] as PaperlessMailRule) @@ -462,6 +549,7 @@ describe('SettingsComponent', () => { }) it('should support delete mail rule, show error if needed', () => { + completeSetup() let modal: NgbModalRef modalService.activeInstances.subscribe((refs) => (modal = refs[0])) component.deleteMailRule(mailRules[0] as PaperlessMailRule) diff --git a/src-ui/src/app/components/manage/settings/settings.component.ts b/src-ui/src/app/components/manage/settings/settings.component.ts index a49f2dd21..785e5d347 100644 --- a/src-ui/src/app/components/manage/settings/settings.component.ts +++ b/src-ui/src/app/components/manage/settings/settings.component.ts @@ -146,7 +146,7 @@ export class SettingsComponent private groupsService: GroupService, private router: Router, private modalService: NgbModal, - private permissionsService: PermissionsService + public permissionsService: PermissionsService ) { super() this.settings.settingsSaved.subscribe(() => { @@ -259,25 +259,73 @@ export class SettingsComponent navID == SettingsNavIDs.UsersGroups && (!this.users || !this.groups) ) { - this.usersService.listAll().subscribe((r) => { - this.users = r.results - this.groupsService.listAll().subscribe((r) => { - this.groups = r.results - this.initialize(false) + this.usersService + .listAll() + .pipe(first()) + .subscribe({ + next: (r) => { + this.users = r.results + this.groupsService + .listAll() + .pipe(first()) + .subscribe({ + next: (r) => { + this.groups = r.results + this.initialize(false) + }, + error: (e) => { + this.toastService.showError( + $localize`Error retrieving groups`, + 10000, + JSON.stringify(e) + ) + }, + }) + }, + error: (e) => { + this.toastService.showError( + $localize`Error retrieving users`, + 10000, + JSON.stringify(e) + ) + }, }) - }) } else if ( navID == SettingsNavIDs.Mail && (!this.mailAccounts || !this.mailRules) ) { - this.mailAccountService.listAll().subscribe((r) => { - this.mailAccounts = r.results + this.mailAccountService + .listAll() + .pipe(first()) + .subscribe({ + next: (r) => { + this.mailAccounts = r.results - this.mailRuleService.listAll().subscribe((r) => { - this.mailRules = r.results - this.initialize(false) + this.mailRuleService + .listAll() + .pipe(first()) + .subscribe({ + next: (r) => { + this.mailRules = r.results + this.initialize(false) + }, + error: (e) => { + this.toastService.showError( + $localize`Error retrieving mail rules`, + 10000, + JSON.stringify(e) + ) + }, + }) + }, + error: (e) => { + this.toastService.showError( + $localize`Error retrieving mail accounts`, + 10000, + JSON.stringify(e) + ) + }, }) - }) } } diff --git a/src-ui/src/app/services/toast.service.ts b/src-ui/src/app/services/toast.service.ts index 2d11d663e..ef282c522 100644 --- a/src-ui/src/app/services/toast.service.ts +++ b/src-ui/src/app/services/toast.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core' -import { Subject, zip } from 'rxjs' +import { Subject } from 'rxjs' export interface Toast { title: string diff --git a/src-ui/src/theme.scss b/src-ui/src/theme.scss index d90afa6c1..33748c81b 100644 --- a/src-ui/src/theme.scss +++ b/src-ui/src/theme.scss @@ -80,6 +80,9 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml, Date: Sun, 20 Aug 2023 13:36:46 -0700 Subject: [PATCH 06/10] Fix: tag creation sometimes retained search text --- .../common/input/tags/tags.component.html | 8 +-- .../common/input/tags/tags.component.spec.ts | 61 +++++++++++++++---- .../common/input/tags/tags.component.ts | 48 +++++++-------- 3 files changed, 71 insertions(+), 46 deletions(-) diff --git a/src-ui/src/app/components/common/input/tags/tags.component.html b/src-ui/src/app/components/common/input/tags/tags.component.html index eba8ef218..497a62335 100644 --- a/src-ui/src/app/components/common/input/tags/tags.component.html +++ b/src-ui/src/app/components/common/input/tags/tags.component.html @@ -2,7 +2,7 @@
        - + (change)="onChange(value)"> diff --git a/src-ui/src/app/components/common/input/tags/tags.component.spec.ts b/src-ui/src/app/components/common/input/tags/tags.component.spec.ts index f3ea05d5d..85c492aba 100644 --- a/src-ui/src/app/components/common/input/tags/tags.component.spec.ts +++ b/src-ui/src/app/components/common/input/tags/tags.component.spec.ts @@ -15,16 +15,28 @@ import { DEFAULT_MATCHING_ALGORITHM, MATCH_ALL, } from 'src/app/data/matching-model' -import { NgSelectModule } from '@ng-select/ng-select' +import { NgSelectComponent, NgSelectModule } from '@ng-select/ng-select' import { RouterTestingModule } from '@angular/router/testing' import { HttpClientTestingModule } from '@angular/common/http/testing' import { of } from 'rxjs' import { TagService } from 'src/app/services/rest/tag.service' import { + NgbAccordionModule, NgbModal, NgbModalModule, NgbModalRef, + NgbPopoverModule, } from '@ng-bootstrap/ng-bootstrap' +import { TagEditDialogComponent } from '../../edit-dialog/tag-edit-dialog/tag-edit-dialog.component' +import { CheckComponent } from '../check/check.component' +import { IfOwnerDirective } from 'src/app/directives/if-owner.directive' +import { TextComponent } from '../text/text.component' +import { ColorComponent } from '../color/color.component' +import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' +import { PermissionsFormComponent } from '../permissions/permissions-form/permissions-form.component' +import { SelectComponent } from '../select/select.component' +import { ColorSliderModule } from 'ngx-color/slider' +import { By } from '@angular/platform-browser' const tags: PaperlessTag[] = [ { @@ -56,12 +68,32 @@ describe('TagsComponent', () => { beforeEach(async () => { TestBed.configureTestingModule({ - declarations: [TagsComponent], + declarations: [ + TagsComponent, + TagEditDialogComponent, + TextComponent, + ColorComponent, + IfOwnerDirective, + SelectComponent, + TextComponent, + PermissionsFormComponent, + ColorComponent, + CheckComponent, + ], providers: [ { provide: TagService, useValue: { - listAll: () => of(tags), + listAll: () => + of({ + results: tags, + }), + create: () => + of({ + name: 'bar', + id: 99, + color: '#fff000', + }), }, }, ], @@ -72,6 +104,8 @@ describe('TagsComponent', () => { RouterTestingModule, HttpClientTestingModule, NgbModalModule, + NgbAccordionModule, + NgbPopoverModule, ], }).compileComponents() @@ -85,7 +119,7 @@ describe('TagsComponent', () => { }) it('should support suggestions', () => { - expect(component.value).toBeUndefined() + expect(component.value).toHaveLength(0) component.value = [] component.tags = tags component.suggestions = [1, 2] @@ -107,19 +141,19 @@ describe('TagsComponent', () => { it('should support create new using last search term and open a modal', () => { let activeInstances: NgbModalRef[] modalService.activeInstances.subscribe((v) => (activeInstances = v)) - component.onSearch({ term: 'bar' }) + component.select.searchTerm = 'foobar' component.createTag() expect(modalService.hasOpenModals()).toBeTruthy() - expect(activeInstances[0].componentInstance.object.name).toEqual('bar') + expect(activeInstances[0].componentInstance.object.name).toEqual('foobar') + const editDialog = activeInstances[0] + .componentInstance as TagEditDialogComponent + editDialog.save() // create is mocked + fixture.detectChanges() + fixture.whenStable().then(() => { + expect(fixture.debugElement.nativeElement.textContent).toContain('foobar') + }) }) - it('should clear search term on blur after delay', fakeAsync(() => { - const clearSpy = jest.spyOn(component, 'clearLastSearchTerm') - component.onBlur() - tick(3000) - expect(clearSpy).toHaveBeenCalled() - })) - it('support remove tags', () => { component.tags = tags component.value = [1, 2] @@ -132,6 +166,7 @@ describe('TagsComponent', () => { }) it('should get tags', () => { + component.tags = null expect(component.getTag(2)).toBeNull() component.tags = tags expect(component.getTag(2)).toEqual(tags[1]) diff --git a/src-ui/src/app/components/common/input/tags/tags.component.ts b/src-ui/src/app/components/common/input/tags/tags.component.ts index 4fb0151b6..b6bfddb3c 100644 --- a/src-ui/src/app/components/common/input/tags/tags.component.ts +++ b/src-ui/src/app/components/common/input/tags/tags.component.ts @@ -5,6 +5,7 @@ import { Input, OnInit, Output, + ViewChild, } from '@angular/core' import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' import { NgbModal } from '@ng-bootstrap/ng-bootstrap' @@ -12,6 +13,8 @@ import { PaperlessTag } from 'src/app/data/paperless-tag' import { TagEditDialogComponent } from '../../edit-dialog/tag-edit-dialog/tag-edit-dialog.component' import { TagService } from 'src/app/services/rest/tag.service' import { EditDialogMode } from '../../edit-dialog/edit-dialog.component' +import { first, firstValueFrom, tap } from 'rxjs' +import { NgSelectComponent } from '@ng-select/ng-select' @Component({ providers: [ @@ -74,14 +77,14 @@ export class TagsComponent implements OnInit, ControlValueAccessor { @Output() filterDocuments = new EventEmitter() - value: number[] + @ViewChild('tagSelect') select: NgSelectComponent - tags: PaperlessTag[] + value: number[] = [] + + tags: PaperlessTag[] = [] public createTagRef: (name) => void - private _lastSearchTerm: string - getTag(id: number) { if (this.tags) { return this.tags.find((tag) => tag.id == id) @@ -111,15 +114,20 @@ export class TagsComponent implements OnInit, ControlValueAccessor { }) modal.componentInstance.dialogMode = EditDialogMode.CREATE if (name) modal.componentInstance.object = { name: name } - else if (this._lastSearchTerm) - modal.componentInstance.object = { name: this._lastSearchTerm } - modal.componentInstance.succeeded.subscribe((newTag) => { - this.tagService.listAll().subscribe((tags) => { - this.tags = tags.results - this.value = [...this.value, newTag.id] - this.onChange(this.value) - }) - }) + else if (this.select.searchTerm) + modal.componentInstance.object = { name: this.select.searchTerm } + this.select.searchTerm = null + this.select.detectChanges() + return firstValueFrom( + (modal.componentInstance as TagEditDialogComponent).succeeded.pipe( + first(), + tap(() => { + this.tagService.listAll().subscribe((tags) => { + this.tags = tags.results + }) + }) + ) + ) } getSuggestions() { @@ -137,20 +145,6 @@ export class TagsComponent implements OnInit, ControlValueAccessor { this.onChange(this.value) } - clearLastSearchTerm() { - this._lastSearchTerm = null - } - - onSearch($event) { - this._lastSearchTerm = $event.term - } - - onBlur() { - setTimeout(() => { - this.clearLastSearchTerm() - }, 3000) - } - get hasPrivate(): boolean { return this.value.some( (t) => this.tags?.find((t2) => t2.id === t) === undefined From 16adddc80316666a04597d950aa8bbd400684803 Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Mon, 21 Aug 2023 13:21:02 -0700 Subject: [PATCH 07/10] Allow users to set a combined certificte and key file for additional certificates in the SSL context --- docs/configuration.md | 13 ++++++++++++ src/paperless/checks.py | 19 ++++++++++++++++- src/paperless/settings.py | 17 ++++++++++++--- src/paperless/tests/test_checks.py | 33 +++++++++++++++++++++++++++++- src/paperless_mail/mail.py | 8 ++++++-- 5 files changed, 83 insertions(+), 7 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index c38221e50..13e628151 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -501,6 +501,19 @@ HTTP header/value expected by Django, eg `'["HTTP_X_FORWARDED_PROTO", "https"]'` Settings this value has security implications. Read the Django documentation and be sure you understand its usage before setting it. +`PAPERLESS_EMAIL_CERTIFICATE_FILE=` + +: Configures an additional SSL certificate file containing a [combined key and certificate](https://docs.python.org/3/library/ssl.html#combined-key-and-certificate) file +for validating SSL connections against mail providers. This is for use with self-signed certificates against +local IMAP servers. + + Defaults to None. + +!!! warning + + Settings this value has security implications for the security of your email. + Understand what it does and be sure you need to before setting. + ## OCR settings {#ocr} Paperless uses [OCRmyPDF](https://ocrmypdf.readthedocs.io/en/latest/) diff --git a/src/paperless/checks.py b/src/paperless/checks.py index cda14baad..d3009d036 100644 --- a/src/paperless/checks.py +++ b/src/paperless/checks.py @@ -177,6 +177,23 @@ def settings_values_check(app_configs, **kwargs): ) return msgs + def _email_certificate_validate(): + msgs = [] + # Existence checks + if ( + settings.EMAIL_CERTIFICATE_FILE is not None + and not settings.EMAIL_CERTIFICATE_FILE.is_file() + ): + msgs.append( + Error( + f"Email cert {settings.EMAIL_CERTIFICATE_FILE} is not a file", + ), + ) + return msgs + return ( - _ocrmypdf_settings_check() + _timezone_validate() + _barcode_scanner_validate() + _ocrmypdf_settings_check() + + _timezone_validate() + + _barcode_scanner_validate() + + _email_certificate_validate() ) diff --git a/src/paperless/settings.py b/src/paperless/settings.py index 7d2dda0d9..6b2ea56b2 100644 --- a/src/paperless/settings.py +++ b/src/paperless/settings.py @@ -67,11 +67,20 @@ def __get_float(key: str, default: float) -> float: return float(os.getenv(key, default)) -def __get_path(key: str, default: Union[PathLike, str]) -> Path: +def __get_path( + key: str, + default: Optional[Union[PathLike, str]] = None, +) -> Optional[Path]: """ - Return a normalized, absolute path based on the environment variable or a default + Return a normalized, absolute path based on the environment variable or a default, + if provided. If not set and no default, returns None """ - return Path(os.environ.get(key, default)).resolve() + if key in os.environ: + return Path(os.environ[key]).resolve() + elif default is not None: + return Path(default).resolve() + else: + return None def __get_list( @@ -477,6 +486,8 @@ CSRF_COOKIE_NAME = f"{COOKIE_PREFIX}csrftoken" SESSION_COOKIE_NAME = f"{COOKIE_PREFIX}sessionid" LANGUAGE_COOKIE_NAME = f"{COOKIE_PREFIX}django_language" +EMAIL_CERTIFICATE_FILE = __get_path("PAPERLESS_EMAIL_CERTIFICATE_FILE") + ############################################################################### # Database # diff --git a/src/paperless/tests/test_checks.py b/src/paperless/tests/test_checks.py index cd706c532..6aac1a4c6 100644 --- a/src/paperless/tests/test_checks.py +++ b/src/paperless/tests/test_checks.py @@ -1,9 +1,11 @@ import os +from pathlib import Path from django.test import TestCase from django.test import override_settings from documents.tests.utils import DirectoriesMixin +from documents.tests.utils import FileSystemAssertsMixin from paperless.checks import binaries_check from paperless.checks import debug_mode_check from paperless.checks import paths_check @@ -57,7 +59,7 @@ class TestChecks(DirectoriesMixin, TestCase): self.assertEqual(len(debug_mode_check(None)), 1) -class TestSettingsChecks(DirectoriesMixin, TestCase): +class TestSettingsChecksAgainstDefaults(DirectoriesMixin, TestCase): def test_all_valid(self): """ GIVEN: @@ -70,6 +72,8 @@ class TestSettingsChecks(DirectoriesMixin, TestCase): msgs = settings_values_check(None) self.assertEqual(len(msgs), 0) + +class TestOcrSettingsChecks(DirectoriesMixin, TestCase): @override_settings(OCR_OUTPUT_TYPE="notapdf") def test_invalid_output_type(self): """ @@ -160,6 +164,8 @@ class TestSettingsChecks(DirectoriesMixin, TestCase): self.assertIn('OCR clean mode "cleanme"', msg.msg) + +class TestTimezoneSettingsChecks(DirectoriesMixin, TestCase): @override_settings(TIME_ZONE="TheMoon\\MyCrater") def test_invalid_timezone(self): """ @@ -178,6 +184,8 @@ class TestSettingsChecks(DirectoriesMixin, TestCase): self.assertIn('Timezone "TheMoon\\MyCrater"', msg.msg) + +class TestBarcodeSettingsChecks(DirectoriesMixin, TestCase): @override_settings(CONSUMER_BARCODE_SCANNER="Invalid") def test_barcode_scanner_invalid(self): msgs = settings_values_check(None) @@ -200,3 +208,26 @@ class TestSettingsChecks(DirectoriesMixin, TestCase): def test_barcode_scanner_valid(self): msgs = settings_values_check(None) self.assertEqual(len(msgs), 0) + + +class TestEmailCertSettingsChecks(DirectoriesMixin, FileSystemAssertsMixin, TestCase): + @override_settings(EMAIL_CERTIFICATE_FILE=Path("/tmp/not_actually_here.pem")) + def test_not_valid_file(self): + """ + GIVEN: + - Default settings + - Email certificate is set + WHEN: + - Email certificate file doesn't exist + THEN: + - system check error reported for email certificate + """ + self.assertIsNotFile("/tmp/not_actually_here.pem") + + msgs = settings_values_check(None) + + self.assertEqual(len(msgs), 1) + + msg = msgs[0] + + self.assertIn("Email cert /tmp/not_actually_here.pem is not a file", msg.msg) diff --git a/src/paperless_mail/mail.py b/src/paperless_mail/mail.py index a0bda19ba..fd66ac91d 100644 --- a/src/paperless_mail/mail.py +++ b/src/paperless_mail/mail.py @@ -395,12 +395,16 @@ def get_mailbox(server, port, security) -> MailBox: """ Returns the correct MailBox instance for the given configuration. """ + ssl_context = ssl.create_default_context() + if settings.EMAIL_CERTIFICATE_FILE is not None: # pragma: nocover + ssl_context.load_cert_chain(certfile=settings.EMAIL_CERTIFICATE_FILE) + if security == MailAccount.ImapSecurity.NONE: mailbox = MailBoxUnencrypted(server, port) elif security == MailAccount.ImapSecurity.STARTTLS: - mailbox = MailBoxTls(server, port, ssl_context=ssl.create_default_context()) + mailbox = MailBoxTls(server, port, ssl_context=ssl_context) elif security == MailAccount.ImapSecurity.SSL: - mailbox = MailBox(server, port, ssl_context=ssl.create_default_context()) + mailbox = MailBox(server, port, ssl_context=ssl_context) else: raise NotImplementedError("Unknown IMAP security") # pragma: nocover return mailbox From df82ac8ac41138752d3fdeb2cd1074b120843a2f Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Wed, 23 Aug 2023 07:28:36 -0700 Subject: [PATCH 08/10] Adjusts to use a different loading of certificates and updates the docs for it --- docs/configuration.md | 6 +++--- src/paperless_mail/mail.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 13e628151..74486660f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -503,9 +503,9 @@ HTTP header/value expected by Django, eg `'["HTTP_X_FORWARDED_PROTO", "https"]'` `PAPERLESS_EMAIL_CERTIFICATE_FILE=` -: Configures an additional SSL certificate file containing a [combined key and certificate](https://docs.python.org/3/library/ssl.html#combined-key-and-certificate) file -for validating SSL connections against mail providers. This is for use with self-signed certificates against -local IMAP servers. +: Configures an additional SSL certificate file containing a [certificate](https://docs.python.org/3/library/ssl.html#certificates) +or certificate chain which should be trusted for validating SSL connections against mail providers. +This is for use with self-signed certificates against local IMAP servers. Defaults to None. diff --git a/src/paperless_mail/mail.py b/src/paperless_mail/mail.py index fd66ac91d..8b41ebacf 100644 --- a/src/paperless_mail/mail.py +++ b/src/paperless_mail/mail.py @@ -397,7 +397,7 @@ def get_mailbox(server, port, security) -> MailBox: """ ssl_context = ssl.create_default_context() if settings.EMAIL_CERTIFICATE_FILE is not None: # pragma: nocover - ssl_context.load_cert_chain(certfile=settings.EMAIL_CERTIFICATE_FILE) + ssl_context.load_verify_locations(cafile=settings.EMAIL_CERTIFICATE_FILE) if security == MailAccount.ImapSecurity.NONE: mailbox = MailBoxUnencrypted(server, port) From fe1f88ce5d1555046bd6ce7525cddc73e1bd6690 Mon Sep 17 00:00:00 2001 From: Trenton Holmes <797416+stumpylog@users.noreply.github.com> Date: Wed, 23 Aug 2023 17:37:23 -0700 Subject: [PATCH 09/10] Sets the http timeouts equal to the task timeout, so it's either done or really done --- src/paperless_mail/parsers.py | 15 ++++++++++++--- src/paperless_tika/parsers.py | 2 +- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/paperless_mail/parsers.py b/src/paperless_mail/parsers.py index 4365d21a4..da9259a69 100644 --- a/src/paperless_mail/parsers.py +++ b/src/paperless_mail/parsers.py @@ -215,7 +215,11 @@ class MailDocumentParser(DocumentParser): file_multi_part[2], ) - response = httpx.post(url_merge, files=pdf_collection, timeout=30.0) + response = httpx.post( + url_merge, + files=pdf_collection, + timeout=settings.CELERY_TASK_TIME_LIMIT, + ) response.raise_for_status() # ensure we notice bad responses archive_path.write_bytes(response.content) @@ -330,7 +334,7 @@ class MailDocumentParser(DocumentParser): files=files, headers=headers, data=data, - timeout=30.0, + timeout=settings.CELERY_TASK_TIME_LIMIT, ) response.raise_for_status() # ensure we notice bad responses except Exception as err: @@ -409,7 +413,12 @@ class MailDocumentParser(DocumentParser): file_multi_part[2], ) - response = httpx.post(url, files=files, data=data, timeout=30.0) + response = httpx.post( + url, + files=files, + data=data, + timeout=settings.CELERY_TASK_TIME_LIMIT, + ) response.raise_for_status() # ensure we notice bad responses except Exception as err: raise ParseError(f"Error while converting document to PDF: {err}") from err diff --git a/src/paperless_tika/parsers.py b/src/paperless_tika/parsers.py index b6a9dd621..402a37215 100644 --- a/src/paperless_tika/parsers.py +++ b/src/paperless_tika/parsers.py @@ -100,7 +100,7 @@ class TikaDocumentParser(DocumentParser): files=files, headers=headers, data=data, - timeout=30.0, + timeout=settings.CELERY_TASK_TIME_LIMIT, ) response.raise_for_status() # ensure we notice bad responses except Exception as err: From a66797437873c5e4c1b7fdc0ece644f84eda720a Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Thu, 24 Aug 2023 00:21:06 -0700 Subject: [PATCH 10/10] Update settings.component.spec.ts --- .../manage/settings/settings.component.spec.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src-ui/src/app/components/manage/settings/settings.component.spec.ts b/src-ui/src/app/components/manage/settings/settings.component.spec.ts index 3919e6499..fb8f0a7f4 100644 --- a/src-ui/src/app/components/manage/settings/settings.component.spec.ts +++ b/src-ui/src/app/components/manage/settings/settings.component.spec.ts @@ -15,6 +15,7 @@ import { NgbModule, NgbNavLink, NgbModalRef, + NgbAlertModule, } from '@ng-bootstrap/ng-bootstrap' import { of, throwError } from 'rxjs' import { routes } from 'src/app/app-routing.module' @@ -42,6 +43,13 @@ import { CheckComponent } from '../../common/input/check/check.component' import { ColorComponent } from '../../common/input/color/color.component' import { PageHeaderComponent } from '../../common/page-header/page-header.component' import { SettingsComponent } from './settings.component' +import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe' +import { SelectComponent } from '../../common/input/select/select.component' +import { TextComponent } from '../../common/input/text/text.component' +import { PasswordComponent } from '../../common/input/password/password.component' +import { NumberComponent } from '../../common/input/number/number.component' +import { TagsComponent } from '../../common/input/tags/tags.component' +import { NgSelectModule } from '@ng-select/ng-select' const savedViews = [ { id: 1, name: 'view1' }, @@ -90,6 +98,14 @@ describe('SettingsComponent', () => { ConfirmDialogComponent, CheckComponent, ColorComponent, + SafeHtmlPipe, + SelectComponent, + TextComponent, + PasswordComponent, + NumberComponent, + TagsComponent, + MailAccountEditDialogComponent, + MailRuleEditDialogComponent, ], providers: [CustomDatePipe, DatePipe, PermissionsGuard], imports: [ @@ -98,6 +114,8 @@ describe('SettingsComponent', () => { RouterTestingModule.withRoutes(routes), FormsModule, ReactiveFormsModule, + NgbAlertModule, + NgSelectModule, ], }).compileComponents()