Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions src/app/core/services/auth.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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`);
});
});
27 changes: 20 additions & 7 deletions src/app/core/services/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 });

Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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}/`)}`;
}
}

Expand Down Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,36 @@ 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';

describe('ResetPasswordComponent', () => {
let component: ResetPasswordComponent;
let fixture: ComponentFixture<ResetPasswordComponent>;
let authService: AuthServiceMockType;
let loaderService: LoaderServiceMock;

const validPassword = 'Password1!';

beforeEach(() => {
const mockRoute = ActivatedRouteMockBuilder.create().withQueryParams({}) as Partial<ActivatedRoute>;
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);
Expand All @@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';

Expand All @@ -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;
Expand Down Expand Up @@ -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() {
Expand Down
Loading