diff --git a/src-ui/src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.spec.ts b/src-ui/src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.spec.ts index 96a0044fe..d8e8d1bc4 100644 --- a/src-ui/src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.spec.ts +++ b/src-ui/src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.spec.ts @@ -7,7 +7,7 @@ import { } from '@angular/forms' import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap' import { NgSelectModule } from '@ng-select/ng-select' -import { of } from 'rxjs' +import { of, throwError } from 'rxjs' import { IfOwnerDirective } from 'src/app/directives/if-owner.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { GroupService } from 'src/app/services/rest/group.service' @@ -21,10 +21,13 @@ import { EditDialogMode } from '../edit-dialog.component' import { UserEditDialogComponent } from './user-edit-dialog.component' import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' +import { ToastService } from 'src/app/services/toast.service' +import { UserService } from 'src/app/services/rest/user.service' describe('UserEditDialogComponent', () => { let component: UserEditDialogComponent let settingsService: SettingsService + let toastService: ToastService let fixture: ComponentFixture beforeEach(async () => { @@ -71,6 +74,7 @@ describe('UserEditDialogComponent', () => { fixture = TestBed.createComponent(UserEditDialogComponent) settingsService = TestBed.inject(SettingsService) settingsService.currentUser = { id: 99, username: 'user99' } + toastService = TestBed.inject(ToastService) component = fixture.componentInstance fixture.detectChanges() @@ -121,4 +125,28 @@ describe('UserEditDialogComponent', () => { component.save() expect(component.passwordIsSet).toBeTruthy() }) + + it('should support deactivation of TOTP', () => { + component.object = { id: 99, username: 'user99' } + const deactivateSpy = jest.spyOn( + component['service'] as UserService, + 'deactivateTotp' + ) + const toastErrorSpy = jest.spyOn(toastService, 'showError') + const toastInfoSpy = jest.spyOn(toastService, 'showInfo') + deactivateSpy.mockReturnValueOnce(throwError(() => new Error('error'))) + component.deactivateTotp() + expect(deactivateSpy).toHaveBeenCalled() + expect(toastErrorSpy).toHaveBeenCalled() + + deactivateSpy.mockReturnValueOnce(of(false)) + component.deactivateTotp() + expect(deactivateSpy).toHaveBeenCalled() + expect(toastErrorSpy).toHaveBeenCalled() + + deactivateSpy.mockReturnValueOnce(of(true)) + component.deactivateTotp() + expect(deactivateSpy).toHaveBeenCalled() + expect(toastInfoSpy).toHaveBeenCalled() + }) }) diff --git a/src-ui/src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.spec.ts b/src-ui/src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.spec.ts index d0af0f2ad..aa793941e 100644 --- a/src-ui/src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.spec.ts +++ b/src-ui/src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.spec.ts @@ -294,4 +294,85 @@ describe('ProfileEditDialogComponent', () => { expect(disconnectSpy).toHaveBeenCalled() expect(component.socialAccounts).not.toContainEqual(socialAccount) }) + + it('should get totp settings', () => { + const settings = { + url: 'http://localhost/', + qr_svg: 'svg', + secret: 'secret', + } + const getSpy = jest.spyOn(profileService, 'getTotpSettings') + const toastSpy = jest.spyOn(toastService, 'showError') + getSpy.mockReturnValueOnce( + throwError(() => new Error('failed to get settings')) + ) + component.gettotpSettings() + expect(getSpy).toHaveBeenCalled() + expect(toastSpy).toHaveBeenCalled() + + getSpy.mockReturnValue(of(settings)) + component.gettotpSettings() + expect(getSpy).toHaveBeenCalled() + expect(component.totpSettings).toEqual(settings) + }) + + it('should activate totp', () => { + const activateSpy = jest.spyOn(profileService, 'activateTotp') + const toastErrorSpy = jest.spyOn(toastService, 'showError') + const toastInfoSpy = jest.spyOn(toastService, 'showInfo') + const error = new Error('failed to activate totp') + activateSpy.mockReturnValueOnce(throwError(() => error)) + component.totpSettings = { + url: 'http://localhost/', + qr_svg: 'svg', + secret: 'secret', + } + component.form.get('totp_code').patchValue('123456') + component.activateTotp() + expect(activateSpy).toHaveBeenCalledWith( + component.totpSettings.secret, + component.form.get('totp_code').value + ) + expect(toastErrorSpy).toHaveBeenCalled() + + activateSpy.mockReturnValueOnce(of({ success: false, recovery_codes: [] })) + component.activateTotp() + expect(toastErrorSpy).toHaveBeenCalledWith('Error activating TOTP', error) + + activateSpy.mockReturnValueOnce( + of({ success: true, recovery_codes: ['1', '2', '3'] }) + ) + component.activateTotp() + expect(toastInfoSpy).toHaveBeenCalled() + expect(component.isTotpEnabled).toBeTruthy() + expect(component.recoveryCodes).toEqual(['1', '2', '3']) + }) + + it('should deactivate totp', () => { + const deactivateSpy = jest.spyOn(profileService, 'deactivateTotp') + const toastErrorSpy = jest.spyOn(toastService, 'showError') + const toastInfoSpy = jest.spyOn(toastService, 'showInfo') + const error = new Error('failed to deactivate totp') + deactivateSpy.mockReturnValueOnce(throwError(() => error)) + component.deactivateTotp() + expect(deactivateSpy).toHaveBeenCalled() + expect(toastErrorSpy).toHaveBeenCalled() + + deactivateSpy.mockReturnValueOnce(of(false)) + component.deactivateTotp() + expect(toastErrorSpy).toHaveBeenCalledWith('Error deactivating TOTP', error) + + deactivateSpy.mockReturnValueOnce(of(true)) + component.deactivateTotp() + expect(toastInfoSpy).toHaveBeenCalled() + expect(component.isTotpEnabled).toBeFalsy() + }) + + it('should copy recovery codes', fakeAsync(() => { + const copySpy = jest.spyOn(clipboard, 'copy') + component.recoveryCodes = ['1', '2', '3'] + component.copyRecoveryCodes() + expect(copySpy).toHaveBeenCalledWith('1\n2\n3') + tick(3000) + })) }) diff --git a/src-ui/src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts b/src-ui/src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts index 637a82701..b4137d58b 100644 --- a/src-ui/src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts +++ b/src-ui/src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts @@ -256,8 +256,6 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy { .pipe(takeUntil(this.unsubscribeNotifier)) .subscribe({ next: (activationResponse) => { - console.log(activationResponse) - this.totpLoading = false this.isTotpEnabled = activationResponse.success this.recoveryCodes = activationResponse.recovery_codes diff --git a/src-ui/src/app/services/permissions.service.spec.ts b/src-ui/src/app/services/permissions.service.spec.ts index f4e01945e..ecbdb6f1b 100644 --- a/src-ui/src/app/services/permissions.service.spec.ts +++ b/src-ui/src/app/services/permissions.service.spec.ts @@ -439,4 +439,25 @@ describe('PermissionsService', () => { expect(permissionsService.isAdmin()).toBeFalsy() }) + + it('correctly checks superuser status', () => { + permissionsService.initialize([], { + username: 'testuser', + last_name: 'User', + first_name: 'Test', + id: 1, + is_superuser: true, + }) + + expect(permissionsService.isSuperUser()).toBeTruthy() + + permissionsService.initialize([], { + username: 'testuser', + last_name: 'User', + first_name: 'Test', + id: 1, + }) + + expect(permissionsService.isSuperUser()).toBeFalsy() + }) }) diff --git a/src-ui/src/app/services/profile.service.spec.ts b/src-ui/src/app/services/profile.service.spec.ts index beb7e9ad5..b7b85ee35 100644 --- a/src-ui/src/app/services/profile.service.spec.ts +++ b/src-ui/src/app/services/profile.service.spec.ts @@ -72,4 +72,32 @@ describe('ProfileService', () => { ) expect(req.request.method).toEqual('GET') }) + + it('calls get totp settings endpoint', () => { + service.getTotpSettings().subscribe() + const req = httpTestingController.expectOne( + `${environment.apiBaseUrl}profile/totp/` + ) + expect(req.request.method).toEqual('GET') + }) + + it('calls activate totp endpoint', () => { + service.activateTotp('secret', 'code').subscribe() + const req = httpTestingController.expectOne( + `${environment.apiBaseUrl}profile/totp/` + ) + expect(req.request.method).toEqual('POST') + expect(req.request.body).toEqual({ + secret: 'secret', + code: 'code', + }) + }) + + it('calls deactivate totp endpoint', () => { + service.deactivateTotp().subscribe() + const req = httpTestingController.expectOne( + `${environment.apiBaseUrl}profile/totp/` + ) + expect(req.request.method).toEqual('DELETE') + }) }) diff --git a/src-ui/src/app/services/rest/user.service.spec.ts b/src-ui/src/app/services/rest/user.service.spec.ts index acf66340a..3fd682d4e 100644 --- a/src-ui/src/app/services/rest/user.service.spec.ts +++ b/src-ui/src/app/services/rest/user.service.spec.ts @@ -160,6 +160,18 @@ const user = { commonAbstractNameFilterPaperlessServiceTests(endpoint, UserService) describe('Additional service tests for UserService', () => { + beforeEach(() => { + // Dont need to setup again + + httpTestingController = TestBed.inject(HttpTestingController) + service = TestBed.inject(UserService) + }) + + afterEach(() => { + subscription?.unsubscribe() + httpTestingController.verify() + }) + it('should retain permissions on update', () => { subscription = service.listAll().subscribe() let req = httpTestingController.expectOne( @@ -179,15 +191,11 @@ describe('Additional service tests for UserService', () => { ) }) - beforeEach(() => { - // Dont need to setup again - - httpTestingController = TestBed.inject(HttpTestingController) - service = TestBed.inject(UserService) - }) - - afterEach(() => { - subscription?.unsubscribe() - httpTestingController.verify() + it('should deactivate totp', () => { + subscription = service.deactivateTotp(user).subscribe() + const req = httpTestingController.expectOne( + `${environment.apiBaseUrl}${endpoint}/${user.id}/deactivate_totp/` + ) + expect(req.request.method).toEqual('POST') }) })