diff --git a/src/app/core/services/auth.service.spec.ts b/src/app/core/services/auth.service.spec.ts new file mode 100644 index 000000000..642191d49 --- /dev/null +++ b/src/app/core/services/auth.service.spec.ts @@ -0,0 +1,104 @@ +import { CookieService } from 'ngx-cookie-service'; +import { MockProvider } from 'ng-mocks'; + +import { PLATFORM_ID } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; + +import { ENVIRONMENT } from '@core/provider/environment.provider'; +import { WINDOW } from '@core/provider/window.provider'; +import { JsonApiService } from '@osf/shared/services/json-api.service'; +import { LoaderService } from '@osf/shared/services/loader.service'; + +import { CookieServiceMock } from '@testing/providers/cookie-service.mock'; +import { JsonApiServiceMock } from '@testing/providers/json-api.service.mock'; +import { LoaderServiceMock } from '@testing/providers/loader-service.mock'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; + +import { AuthService } from './auth.service'; + +describe('AuthService', () => { + const webUrl = 'https://web.test'; + const casUrl = 'https://cas.test'; + const origin = 'https://osf.test'; + + let service: AuthService; + let loaderService: LoaderServiceMock; + let locationMock: { href: string; pathname: string; origin: string }; + + function setup(overrides: { pathname?: string; href?: string; isBrowser?: boolean } = {}) { + const pathname = overrides.pathname ?? '/dashboard'; + locationMock = { + href: overrides.href ?? `${origin}${pathname}`, + pathname, + origin, + }; + + loaderService = new LoaderServiceMock(); + + TestBed.configureTestingModule({ + providers: [ + AuthService, + provideMockStore(), + MockProvider(JsonApiService, JsonApiServiceMock.simple()), + MockProvider(CookieService, CookieServiceMock.simple()), + MockProvider(LoaderService, loaderService), + MockProvider(WINDOW, { location: locationMock } as Window), + MockProvider(ENVIRONMENT, { webUrl, casUrl, apiDomainUrl: 'https://api.test' }), + MockProvider(PLATFORM_ID, overrides.isBrowser === false ? 'server' : 'browser'), + ], + }); + + service = TestBed.inject(AuthService); + } + + function nextFromServiceParam(href: string): string | null { + const serviceParam = new URL(href).searchParams.get('service'); + return serviceParam ? new URL(serviceParam).searchParams.get('next') : null; + } + + it('should use the current page as next when signing in from a regular page', () => { + const href = `${origin}/project/abc`; + setup({ pathname: '/project/abc', href }); + + service.navigateToSignIn(); + + expect(loaderService.show).toHaveBeenCalled(); + expect(nextFromServiceParam(locationMock.href)).toBe(href); + }); + + it('should use home as next when signing in from reset password', () => { + setup({ + pathname: '/resetpassword/user-1/token-1', + href: `${origin}/resetpassword/user-1/token-1`, + }); + + service.navigateToSignIn(); + + expect(nextFromServiceParam(locationMock.href)).toBe(`${webUrl}/`); + }); + + it('should use home as next when signing in from forgot password with orcid', () => { + setup({ pathname: '/forgotpassword', href: `${origin}/forgotpassword` }); + + service.navigateToOrcidSignIn(); + + expect(new URL(locationMock.href).searchParams.get('next')).toBe(`${webUrl}/`); + }); + + it('should use home as next when signing in from register with institution', () => { + setup({ pathname: '/register', href: `${origin}/register` }); + + service.navigateToInstitutionSignIn(); + + expect(new URL(locationMock.href).searchParams.get('next')).toBe(`${webUrl}/`); + }); + + it('should not redirect to sign in when not in the browser', () => { + setup({ isBrowser: false, href: `${origin}/dashboard` }); + + service.navigateToSignIn(); + + expect(loaderService.show).not.toHaveBeenCalled(); + expect(locationMock.href).toBe(`${origin}/dashboard`); + }); +}); diff --git a/src/app/core/services/auth.service.ts b/src/app/core/services/auth.service.ts index 2ced17078..70a3328e6 100644 --- a/src/app/core/services/auth.service.ts +++ b/src/app/core/services/auth.service.ts @@ -7,6 +7,7 @@ import { inject, Injectable, PLATFORM_ID } from '@angular/core'; import { SignUpModel } from '@core/models/sign-up.model'; import { ENVIRONMENT } from '@core/provider/environment.provider'; +import { WINDOW } from '@core/provider/window.provider'; import { ClearCurrentUser } from '@osf/core/store/user'; import { urlParam } from '@osf/shared/helpers/url-param.helper'; import { JsonApiService } from '@osf/shared/services/json-api.service'; @@ -20,6 +21,7 @@ export class AuthService { private readonly cookieService = inject(CookieService); private readonly loaderService = inject(LoaderService); private readonly environment = inject(ENVIRONMENT); + private readonly window = inject(WINDOW); private readonly platformId = inject(PLATFORM_ID); private readonly actions = createDispatchMap({ clearCurrentUser: ClearCurrentUser }); @@ -43,12 +45,12 @@ export class AuthService { this.loaderService.show(); const serviceUrl = new URL(`${this.webUrl}/login`); - serviceUrl.searchParams.set('next', window.location.href); + serviceUrl.searchParams.set('next', this.getPostLoginRedirectUrl()); const loginUrl = new URL(`${this.casUrl}/login`); loginUrl.searchParams.set('service', serviceUrl.toString()); - window.location.href = loginUrl.toString(); + this.window.location.href = loginUrl.toString(); } navigateToOrcidSignIn(): void { @@ -59,10 +61,10 @@ export class AuthService { const loginUrl = `${this.casUrl}/login?${urlParam({ redirectOrcid: 'true', service: `${this.webUrl}/login`, - next: window.location.href, + next: this.getPostLoginRedirectUrl(), })}`; - window.location.href = loginUrl; + this.window.location.href = loginUrl; } navigateToInstitutionSignIn(): void { @@ -73,10 +75,10 @@ export class AuthService { const loginUrl = `${this.casUrl}/login?${urlParam({ campaign: 'institution', service: `${this.webUrl}/login`, - next: window.location.href, + next: this.getPostLoginRedirectUrl(), })}`; - window.location.href = loginUrl; + this.window.location.href = loginUrl; } logout(nextUrl?: string): void { @@ -85,7 +87,7 @@ export class AuthService { if (isPlatformBrowser(this.platformId)) { this.cookieService.deleteAll(); - window.location.href = `${this.webUrl}/logout/?next=${encodeURIComponent(nextUrl || `${window.location.origin}/`)}`; + this.window.location.href = `${this.webUrl}/logout/?next=${encodeURIComponent(nextUrl || `${this.window.location.origin}/`)}`; } } @@ -130,4 +132,15 @@ export class AuthService { return this.jsonApiService.post(baseUrl, body); } + + private getPostLoginRedirectUrl(): string { + const pathname = this.window.location.pathname; + const excludedPaths = ['/resetpassword', '/forgotpassword', '/register', '/resend']; + + if (excludedPaths.some((path) => pathname === path || pathname.startsWith(`${path}/`))) { + return `${this.webUrl}/`; + } + + return this.window.location.href; + } } diff --git a/src/app/features/auth/pages/reset-password/reset-password.component.spec.ts b/src/app/features/auth/pages/reset-password/reset-password.component.spec.ts index 18d523a3c..ce9fd240f 100644 --- a/src/app/features/auth/pages/reset-password/reset-password.component.spec.ts +++ b/src/app/features/auth/pages/reset-password/reset-password.component.spec.ts @@ -5,8 +5,11 @@ import { ActivatedRoute } from '@angular/router'; import { AuthService } from '@core/services/auth.service'; import { PasswordInputHintComponent } from '@osf/shared/components/password-input-hint/password-input-hint.component'; +import { LoaderService } from '@osf/shared/services/loader.service'; import { provideOSFCore } from '@testing/osf.testing.provider'; +import { AuthServiceMock, AuthServiceMockType } from '@testing/providers/auth-service.mock'; +import { LoaderServiceMock } from '@testing/providers/loader-service.mock'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; import { ResetPasswordComponent } from './reset-password.component'; @@ -14,13 +17,24 @@ import { ResetPasswordComponent } from './reset-password.component'; describe('ResetPasswordComponent', () => { let component: ResetPasswordComponent; let fixture: ComponentFixture; + let authService: AuthServiceMockType; + let loaderService: LoaderServiceMock; + + const validPassword = 'Password1!'; beforeEach(() => { - const mockRoute = ActivatedRouteMockBuilder.create().withQueryParams({}) as Partial; + authService = AuthServiceMock.simple(); + loaderService = new LoaderServiceMock(); + const mockRoute = ActivatedRouteMockBuilder.create().withParams({ userId: 'user-1', token: 'token-1' }).build(); TestBed.configureTestingModule({ imports: [ResetPasswordComponent, MockComponent(PasswordInputHintComponent)], - providers: [provideOSFCore(), MockProvider(AuthService), MockProvider(ActivatedRoute, mockRoute)], + providers: [ + provideOSFCore(), + MockProvider(AuthService, authService), + MockProvider(LoaderService, loaderService), + MockProvider(ActivatedRoute, mockRoute), + ], }); fixture = TestBed.createComponent(ResetPasswordComponent); @@ -31,4 +45,47 @@ describe('ResetPasswordComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('should not reset password when form is invalid', () => { + component.onSubmit(); + + expect(authService.resetPassword).not.toHaveBeenCalled(); + expect(loaderService.show).not.toHaveBeenCalled(); + }); + + it('should show mismatch error when passwords differ and both fields are dirty', () => { + component.resetPasswordForm.setValue({ + newPassword: validPassword, + confirmNewPassword: 'Different1!', + }); + component.resetPasswordForm.get('newPassword')?.markAsDirty(); + component.resetPasswordForm.get('confirmNewPassword')?.markAsDirty(); + fixture.detectChanges(); + + expect(component.isMismatchError).toBe(true); + expect(fixture.nativeElement.querySelector('p-message')).toBeTruthy(); + }); + + it('should reset password, toggle loader, and show success', () => { + component.resetPasswordForm.setValue({ + newPassword: validPassword, + confirmNewPassword: validPassword, + }); + + component.onSubmit(); + fixture.detectChanges(); + + expect(loaderService.show).toHaveBeenCalled(); + expect(authService.resetPassword).toHaveBeenCalledWith('user-1', 'token-1', validPassword); + expect(loaderService.hide).toHaveBeenCalled(); + expect(component.isFormSubmitted()).toBe(true); + expect(fixture.nativeElement.querySelector('.reset-password-container')).toBeNull(); + expect(fixture.nativeElement.querySelector('.message-container')).toBeTruthy(); + }); + + it('should navigate to sign in', () => { + component.backToSignIn(); + + expect(authService.navigateToSignIn).toHaveBeenCalled(); + }); }); diff --git a/src/app/features/auth/pages/reset-password/reset-password.component.ts b/src/app/features/auth/pages/reset-password/reset-password.component.ts index 558b7574b..8ec5fe58a 100644 --- a/src/app/features/auth/pages/reset-password/reset-password.component.ts +++ b/src/app/features/auth/pages/reset-password/reset-password.component.ts @@ -4,6 +4,8 @@ import { Button } from 'primeng/button'; import { Message } from 'primeng/message'; import { Password } from 'primeng/password'; +import { finalize } from 'rxjs'; + import { Component, inject, signal } from '@angular/core'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { ActivatedRoute } from '@angular/router'; @@ -12,6 +14,7 @@ import { AuthService } from '@core/services/auth.service'; import { PasswordInputHintComponent } from '@osf/shared/components/password-input-hint/password-input-hint.component'; import { CustomValidators } from '@osf/shared/helpers/custom-form-validators.helper'; import { PASSWORD_REGEX } from '@osf/shared/helpers/password.helper'; +import { LoaderService } from '@osf/shared/services/loader.service'; import { ResetPasswordFormGroupType } from '../../models'; @@ -25,6 +28,7 @@ export class ResetPasswordComponent { private readonly fb = inject(FormBuilder); private readonly route = inject(ActivatedRoute); private readonly authService = inject(AuthService); + private readonly loaderService = inject(LoaderService); isFormSubmitted = signal(false); passwordRegex = PASSWORD_REGEX; @@ -59,9 +63,11 @@ export class ResetPasswordComponent { const token = this.route.snapshot.params['token']; const newPassword = this.resetPasswordForm.getRawValue().newPassword; - this.authService.resetPassword(userId, token, newPassword).subscribe(() => { - this.isFormSubmitted.set(true); - }); + this.loaderService.show(); + this.authService + .resetPassword(userId, token, newPassword) + .pipe(finalize(() => this.loaderService.hide())) + .subscribe(() => this.isFormSubmitted.set(true)); } backToSignIn() {