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
Original file line number Diff line number Diff line change
@@ -1,53 +1,63 @@
import { CookieService } from 'ngx-cookie-service';
import { MockComponent, MockProvider } from 'ng-mocks';

import { PLATFORM_ID } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';

import { CookieConsentService } from '@core/services/cookie-consent.service';
import { IconComponent } from '@osf/shared/components/icon/icon.component';

import { provideOSFCore } from '@testing/osf.testing.provider';
import { CookieServiceMock } from '@testing/providers/cookie-service.mock';
import { CookieConsentServiceMock, CookieConsentServiceMockType } from '@testing/providers/cookie-consent.service.mock';

import { CookieConsentBannerComponent } from './cookie-consent-banner.component';

describe('Component: Cookie Consent Banner', () => {
let fixture: ComponentFixture<CookieConsentBannerComponent>;
let component: CookieConsentBannerComponent;
let cookieConsentMock: CookieConsentServiceMockType;

const cookieServiceMock = CookieServiceMock.simple();
function setup(overrides: { hasConsent?: boolean; isBrowser?: boolean } = {}) {
cookieConsentMock = CookieConsentServiceMock.simple();
cookieConsentMock.hasConsent.mockReturnValue(overrides.hasConsent ?? false);

beforeEach(() => {
TestBed.configureTestingModule({
imports: [CookieConsentBannerComponent, MockComponent(IconComponent)],
providers: [provideOSFCore(), MockProvider(CookieService, cookieServiceMock)],
providers: [
provideOSFCore(),
MockProvider(CookieConsentService, cookieConsentMock),
MockProvider(PLATFORM_ID, overrides.isBrowser === false ? 'server' : 'browser'),
],
});
});

it('should show the banner if cookie is not set', () => {
cookieServiceMock.check.mockReturnValue(false);
fixture = TestBed.createComponent(CookieConsentBannerComponent);
component = fixture.componentInstance;
}

it('should show the banner when consent is missing', () => {
setup();

expect(component.displayBanner()).toBe(true);
});

it('should hide the banner if cookie is set', () => {
cookieServiceMock.check.mockReturnValue(true);
fixture = TestBed.createComponent(CookieConsentBannerComponent);
component = fixture.componentInstance;
it('should hide the banner when consent is already recorded', () => {
setup({ hasConsent: true });

expect(component.displayBanner()).toBe(false);
});

it('should set cookie and hide banner on acceptCookies()', () => {
cookieServiceMock.check.mockReturnValue(false);
fixture = TestBed.createComponent(CookieConsentBannerComponent);
component = fixture.componentInstance;
it('should keep the banner hidden outside the browser', () => {
setup({ isBrowser: false });

component.acceptCookies();
expect(component.displayBanner()).toBe(false);
expect(cookieConsentMock.hasConsent).not.toHaveBeenCalled();
});

expect(cookieServiceMock.set).toHaveBeenCalledWith('cookie-consent', 'true', new Date('9999-12-31T23:59:59Z'), '/');
it('should accept cookies and hide the banner', () => {
setup();

component.acceptCookies();

expect(cookieConsentMock.accept).toHaveBeenCalled();
expect(component.displayBanner()).toBe(false);
});
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { CookieService } from 'ngx-cookie-service';
import { TranslatePipe } from '@ngx-translate/core';

import { Button } from 'primeng/button';
Expand All @@ -7,12 +6,13 @@ import { Message } from 'primeng/message';
import { isPlatformBrowser } from '@angular/common';
import { ChangeDetectionStrategy, Component, inject, PLATFORM_ID, signal } from '@angular/core';

import { CookieConsentService } from '@core/services/cookie-consent.service';
import { IconComponent } from '@osf/shared/components/icon/icon.component';

/**
* Displays a cookie consent banner until the user accepts.
*
* - Uses `ngx-cookie-service` to persist acceptance across sessions.
* - Persists acceptance through `CookieConsentService`.
* - Automatically hides the banner if consent is already recorded.
* - Supports translation via `TranslatePipe`.
*/
Expand All @@ -24,30 +24,22 @@ import { IconComponent } from '@osf/shared/components/icon/icon.component';
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CookieConsentBannerComponent {
/**
* The name of the cookie used to track whether the user accepted cookies.
*/
private readonly cookieName = 'cookie-consent';

/**
* Signal controlling the visibility of the cookie banner.
* Set to `true` if the user has not accepted cookies yet.
*/
readonly displayBanner = signal<boolean>(false);

/**
* Cookie service used to persist dismissal state in the browser.
*/
private readonly cookies = inject(CookieService);
private readonly platformId = inject(PLATFORM_ID);
private readonly cookieConsent = inject(CookieConsentService);
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));

/**
* Initializes the component and sets the banner display
* based on the existence of the cookie.
*/
constructor() {
if (isPlatformBrowser(this.platformId)) {
this.displayBanner.set(!this.cookies.check(this.cookieName));
if (this.isBrowser) {
this.displayBanner.set(!this.cookieConsent.hasConsent());
Comment on lines +41 to +42

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or does this keep it from showing on SSR. If so, why the exception for non-browsers in the service?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The non-browser return in the service was an extra guard. It can be removed from service.

}
}

Expand All @@ -57,10 +49,7 @@ export class CookieConsentBannerComponent {
* - Hides the banner immediately.
*/
acceptCookies() {
if (isPlatformBrowser(this.platformId)) {
const expireDate = new Date('9999-12-31T23:59:59Z');
this.cookies.set(this.cookieName, 'true', expireDate, '/');
}
this.cookieConsent.accept();
this.displayBanner.set(false);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Button } from 'primeng/button';
import { Checkbox } from 'primeng/checkbox';
import { Message } from 'primeng/message';

import { Component, computed, signal } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';

Expand All @@ -32,6 +32,7 @@ import { IconComponent } from '@osf/shared/components/icon/icon.component';
selector: 'osf-tos-consent-banner',
imports: [FormsModule, Checkbox, Button, Message, TranslatePipe, IconComponent, RouterLink],
templateUrl: './tos-consent-banner.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TosConsentBannerComponent {
/**
Expand Down
58 changes: 55 additions & 3 deletions src/app/core/services/auth.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { Store } from '@ngxs/store';

import { CookieService } from 'ngx-cookie-service';
import { MockProvider } from 'ng-mocks';

Expand All @@ -6,10 +8,13 @@ import { TestBed } from '@angular/core/testing';

import { ENVIRONMENT } from '@core/provider/environment.provider';
import { WINDOW } from '@core/provider/window.provider';
import { CookieConsentService } from '@core/services/cookie-consent.service';
import { ClearCurrentUser } from '@osf/core/store/user';
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 { CookieConsentServiceMock, CookieConsentServiceMockType } from '@testing/providers/cookie-consent.service.mock';
import { CookieServiceMock, CookieServiceMockType } 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';
Expand All @@ -22,10 +27,15 @@ describe('AuthService', () => {
const origin = 'https://osf.test';

let service: AuthService;
let store: Store;
let loaderService: LoaderServiceMock;
let cookieServiceMock: CookieServiceMockType;
let cookieConsentMock: CookieConsentServiceMockType;
let locationMock: { href: string; pathname: string; origin: string };

function setup(overrides: { pathname?: string; href?: string; isBrowser?: boolean } = {}) {
function setup(
overrides: { pathname?: string; href?: string; isBrowser?: boolean; hasCookieConsent?: boolean } = {}
) {
const pathname = overrides.pathname ?? '/dashboard';
locationMock = {
href: overrides.href ?? `${origin}${pathname}`,
Expand All @@ -34,13 +44,17 @@ describe('AuthService', () => {
};

loaderService = new LoaderServiceMock();
cookieServiceMock = CookieServiceMock.simple();
cookieConsentMock = CookieConsentServiceMock.simple();
cookieConsentMock.hasConsent.mockReturnValue(overrides.hasCookieConsent ?? false);

TestBed.configureTestingModule({
providers: [
AuthService,
provideMockStore(),
MockProvider(JsonApiService, JsonApiServiceMock.simple()),
MockProvider(CookieService, CookieServiceMock.simple()),
MockProvider(CookieService, cookieServiceMock),
MockProvider(CookieConsentService, cookieConsentMock),
MockProvider(LoaderService, loaderService),
MockProvider(WINDOW, { location: locationMock } as Window),
MockProvider(ENVIRONMENT, { webUrl, casUrl, apiDomainUrl: 'https://api.test' }),
Expand All @@ -49,6 +63,7 @@ describe('AuthService', () => {
});

service = TestBed.inject(AuthService);
store = TestBed.inject(Store);
}

function nextFromServiceParam(href: string): string | null {
Expand Down Expand Up @@ -101,4 +116,41 @@ describe('AuthService', () => {
expect(loaderService.show).not.toHaveBeenCalled();
expect(locationMock.href).toBe(`${origin}/dashboard`);
});

it('should clear session cookies and restore cookie consent on logout', () => {
setup({ hasCookieConsent: true });

service.logout();

expect(loaderService.show).toHaveBeenCalled();
expect(store.dispatch).toHaveBeenCalledWith(new ClearCurrentUser());
expect(cookieConsentMock.hasConsent.mock.invocationCallOrder[0]).toBeLessThan(
cookieServiceMock.deleteAll.mock.invocationCallOrder[0]
);
expect(cookieServiceMock.deleteAll.mock.invocationCallOrder[0]).toBeLessThan(
cookieConsentMock.accept.mock.invocationCallOrder[0]
);
expect(locationMock.href).toBe(`${webUrl}/logout/?next=${encodeURIComponent(`${origin}/`)}`);
});

it('should not restore cookie consent on logout when the user has not accepted', () => {
setup();

service.logout();

expect(cookieServiceMock.deleteAll).toHaveBeenCalled();
expect(cookieConsentMock.accept).not.toHaveBeenCalled();
});

it('should not clear cookies when logging out outside the browser', () => {
setup({ isBrowser: false, hasCookieConsent: true, href: `${origin}/dashboard` });

service.logout();

expect(loaderService.show).toHaveBeenCalled();
expect(store.dispatch).toHaveBeenCalledWith(new ClearCurrentUser());
expect(cookieServiceMock.deleteAll).not.toHaveBeenCalled();
expect(cookieConsentMock.hasConsent).not.toHaveBeenCalled();
expect(locationMock.href).toBe(`${origin}/dashboard`);
});
});
19 changes: 14 additions & 5 deletions src/app/core/services/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,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 { CookieConsentService } from '@core/services/cookie-consent.service';
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 @@ -19,10 +20,11 @@ import { LoaderService } from '@osf/shared/services/loader.service';
export class AuthService {
private readonly jsonApiService = inject(JsonApiService);
private readonly cookieService = inject(CookieService);
private readonly cookieConsent = inject(CookieConsentService);
private readonly loaderService = inject(LoaderService);
private readonly environment = inject(ENVIRONMENT);
private readonly window = inject(WINDOW);
private readonly platformId = inject(PLATFORM_ID);
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
private readonly actions = createDispatchMap({ clearCurrentUser: ClearCurrentUser });

get apiUrl() {
Expand All @@ -38,7 +40,7 @@ export class AuthService {
}

navigateToSignIn(): void {
if (!isPlatformBrowser(this.platformId)) {
if (!this.isBrowser) {
return;
}

Expand All @@ -54,7 +56,7 @@ export class AuthService {
}

navigateToOrcidSignIn(): void {
if (!isPlatformBrowser(this.platformId)) {
if (!this.isBrowser) {
return;
}

Expand All @@ -68,7 +70,7 @@ export class AuthService {
}

navigateToInstitutionSignIn(): void {
if (!isPlatformBrowser(this.platformId)) {
if (!this.isBrowser) {
return;
}

Expand All @@ -85,8 +87,15 @@ export class AuthService {
this.loaderService.show();
this.actions.clearCurrentUser();

if (isPlatformBrowser(this.platformId)) {
if (this.isBrowser) {
const hasCookieConsent = this.cookieConsent.hasConsent();

this.cookieService.deleteAll();

if (hasCookieConsent) {
this.cookieConsent.accept();
}

this.window.location.href = `${this.webUrl}/logout/?next=${encodeURIComponent(nextUrl || `${this.window.location.origin}/`)}`;
}
}
Expand Down
41 changes: 41 additions & 0 deletions src/app/core/services/cookie-consent.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { CookieService } from 'ngx-cookie-service';
import { MockProvider } from 'ng-mocks';

import { TestBed } from '@angular/core/testing';

import { CookieServiceMock, CookieServiceMockType } from '@testing/providers/cookie-service.mock';

import { CookieConsentService } from './cookie-consent.service';

describe('CookieConsentService', () => {
let service: CookieConsentService;
let cookieServiceMock: CookieServiceMockType;

beforeEach(() => {
cookieServiceMock = CookieServiceMock.simple();

TestBed.configureTestingModule({
providers: [CookieConsentService, MockProvider(CookieService, cookieServiceMock)],
});

service = TestBed.inject(CookieConsentService);
});

it('should report consent when the cookie exists', () => {
cookieServiceMock.check.mockReturnValue(true);

expect(service.hasConsent()).toBe(true);
expect(cookieServiceMock.check).toHaveBeenCalledWith('cookie-consent');
});

it('should report no consent when the cookie is missing', () => {
expect(service.hasConsent()).toBe(false);
expect(cookieServiceMock.check).toHaveBeenCalledWith('cookie-consent');
});

it('should store consent until year 9999', () => {
service.accept();

expect(cookieServiceMock.set).toHaveBeenCalledWith('cookie-consent', 'true', new Date('9999-12-31T23:59:59Z'), '/');
});
});
17 changes: 17 additions & 0 deletions src/app/core/services/cookie-consent.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { CookieService } from 'ngx-cookie-service';

import { inject, Injectable } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class CookieConsentService {
private readonly cookies = inject(CookieService);
private readonly cookieName = 'cookie-consent';

hasConsent(): boolean {
return this.cookies.check(this.cookieName);
}

accept(): void {
this.cookies.set(this.cookieName, 'true', new Date('9999-12-31T23:59:59Z'), '/');
}
}
Loading
Loading