Skip to content
Open
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
Expand Up @@ -6,8 +6,10 @@ import {
waitForAsync,
} from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { of } from 'rxjs';
import { NotifyRequestsStatusDataService } from 'src/app/core/data/notify-services-status-data.service';

import { NotifyInfoService } from '../../../../core/coar-notify/notify-info/notify-info.service';
import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils';
import { NotifyRequestsStatus } from '../notify-requests-status.model';
import { RequestStatusEnum } from '../notify-status.enum';
Expand All @@ -18,6 +20,7 @@ describe('NotifyRequestsStatusComponent', () => {
let component: NotifyRequestsStatusComponent;
let fixture: ComponentFixture<NotifyRequestsStatusComponent>;
let notifyInfoServiceSpy;
let notifyInfoSpy;

const mock: NotifyRequestsStatus = Object.assign(new NotifyRequestsStatus(), {
notifyStatus: [],
Expand All @@ -28,10 +31,14 @@ describe('NotifyRequestsStatusComponent', () => {
notifyInfoServiceSpy = {
getNotifyRequestsStatus:() => createSuccessfulRemoteDataObject$(mock),
};
notifyInfoSpy = {
isCoarConfigEnabled: () => of(true),
};
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), NotifyRequestsStatusComponent],
providers: [
{ provide: NotifyRequestsStatusDataService, useValue: notifyInfoServiceSpy },
{ provide: NotifyInfoService, useValue: notifyInfoSpy },
],
}).overrideComponent(NotifyRequestsStatusComponent, {
remove: {
Expand Down Expand Up @@ -66,6 +73,18 @@ describe('NotifyRequestsStatusComponent', () => {
});
}));

it('should not ask the backend for the request status when COAR Notify is disabled', fakeAsync(() => {
spyOn(notifyInfoSpy, 'isCoarConfigEnabled').and.returnValue(of(false));
spyOn(notifyInfoServiceSpy, 'getNotifyRequestsStatus').and.callThrough();

component.itemUuid = 'testUuid';
component.ngOnInit();
component.requestMap$.subscribe();
tick();

expect(notifyInfoServiceSpy.getNotifyRequestsStatus).not.toHaveBeenCalled();
}));

it('should group data by status', () => {
const mockData: NotifyRequestsStatus = Object.assign(new NotifyRequestsStatus(), {
notifyStatus: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,14 @@ import {
OnInit,
} from '@angular/core';
import {
EMPTY,
filter,
map,
Observable,
switchMap,
} from 'rxjs';

import { NotifyInfoService } from '../../../../core/coar-notify/notify-info/notify-info.service';
import { NotifyRequestsStatusDataService } from '../../../../core/data/notify-services-status-data.service';
import {
getFirstCompletedRemoteData,
Expand Down Expand Up @@ -55,20 +58,30 @@ export class NotifyRequestsStatusComponent implements OnInit {
*/
requestMap$: Observable<Map<RequestStatusEnum, NotifyStatuses[]>>;

constructor(private notifyInfoService: NotifyRequestsStatusDataService) { }
constructor(
private notifyRequestsStatusDataService: NotifyRequestsStatusDataService,
private notifyInfoService: NotifyInfoService,
) { }

ngOnInit(): void {
this.requestMap$ = this.notifyInfoService
.getNotifyRequestsStatus(this.itemUuid)
.pipe(
getFirstCompletedRemoteData(),
filter((data) => hasValue(data)),
getRemoteDataPayload(),
filter((data: NotifyRequestsStatus) => hasValue(data)),
map((data: NotifyRequestsStatus) => {
return this.groupDataByStatus(data);
}),
);
// /api/ldn/notifyrequests/<uuid> answers 401 unless COAR Notify is both enabled and visible to
// the current user, so this was a guaranteed error on every item page for anonymous visitors.
// The same feature flag already gates the COAR links built by ItemPageComponent.
this.requestMap$ = this.notifyInfoService.isCoarConfigEnabled().pipe(
switchMap((coarEnabled: boolean) => coarEnabled
? this.notifyRequestsStatusDataService
.getNotifyRequestsStatus(this.itemUuid)
.pipe(
getFirstCompletedRemoteData(),
filter((data) => hasValue(data)),
getRemoteDataPayload(),
filter((data: NotifyRequestsStatus) => hasValue(data)),
map((data: NotifyRequestsStatus) => {
return this.groupDataByStatus(data);
}),
)
: EMPTY),
);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { SplitPipe } from 'src/app/shared/utils/split.pipe';
import { APP_DATA_SERVICES_MAP } from '../../../../config/app-config.interface';
import { RemoteDataBuildService } from '../../../core/cache/builders/remote-data-build.service';
import { ObjectCacheService } from '../../../core/cache/object-cache.service';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
import { RequestService } from '../../../core/data/request.service';
import { QualityAssuranceSourceObject } from '../../../core/notifications/qa/models/quality-assurance-source.model';
import { QualityAssuranceSourceDataService } from '../../../core/notifications/qa/source/quality-assurance-source-data.service';
Expand All @@ -28,6 +30,7 @@ describe('QaEventNotificationComponent', () => {
let component: QaEventNotificationComponent;
let fixture: ComponentFixture<QaEventNotificationComponent>;
let qualityAssuranceSourceDataServiceStub: any;
let authorizationServiceStub: any;

const obj = Object.assign(new QualityAssuranceSourceObject(), {
id: 'sourceName:target',
Expand All @@ -43,12 +46,16 @@ describe('QaEventNotificationComponent', () => {
qualityAssuranceSourceDataServiceStub = {
getSourcesByTarget: () => objPL,
};
authorizationServiceStub = {
isAuthorized: () => of(true),
};
await TestBed.configureTestingModule({
imports: [CommonModule, TranslateModule.forRoot(), QaEventNotificationComponent, SplitPipe],
providers: [
{ provide: APP_DATA_SERVICES_MAP, useValue: {} },
{ provide: ActivatedRoute, useValue: new ActivatedRouteStub() },
{ provide: QualityAssuranceSourceDataService, useValue: qualityAssuranceSourceDataServiceStub },
{ provide: AuthorizationDataService, useValue: authorizationServiceStub },
{ provide: RequestService, useValue: {} },
{ provide: NotificationsService, useValue: {} },
{ provide: HALEndpointService, useValue: new HALEndpointServiceStub('test') },
Expand Down Expand Up @@ -78,4 +85,30 @@ describe('QaEventNotificationComponent', () => {
const route = component.getQualityAssuranceRoute();
expect(route).toBe('/notifications/quality-assurance');
});

it('should ask for the sources when the user is allowed to see QA events', (done) => {
// the data service is provided by the component itself, so spy on the instance it actually uses
const sourceService = fixture.debugElement.injector.get(QualityAssuranceSourceDataService);
spyOn(sourceService, 'getSourcesByTarget').and.returnValue(objPL);
spyOn(authorizationServiceStub, 'isAuthorized').and.returnValue(of(true));

component.getQualityAssuranceSources$().subscribe((sources) => {
expect(authorizationServiceStub.isAuthorized).toHaveBeenCalledWith(FeatureID.CanSeeQA);
expect(sourceService.getSourcesByTarget).toHaveBeenCalled();
expect(sources).toEqual([obj]);
done();
});
});

it('should not ask for the sources when the user cannot see QA events', (done) => {
const sourceService = fixture.debugElement.injector.get(QualityAssuranceSourceDataService);
spyOn(sourceService, 'getSourcesByTarget').and.returnValue(objPL);
spyOn(authorizationServiceStub, 'isAuthorized').and.returnValue(of(false));

component.getQualityAssuranceSources$().subscribe((sources) => {
expect(sourceService.getSourcesByTarget).not.toHaveBeenCalled();
expect(sources).toEqual([]);
done();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,20 @@ import {
} from '@angular/core';
import { RouterLink } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { Observable } from 'rxjs';
import {
Observable,
of,
} from 'rxjs';
import {
catchError,
map,
switchMap,
} from 'rxjs/operators';

import { getNotificatioQualityAssuranceRoute } from '../../../admin/admin-routing-paths';
import { RequestParam } from '../../../core/cache/models/request-param.model';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
import { FindListOptions } from '../../../core/data/find-list-options.model';
import { PaginatedList } from '../../../core/data/paginated-list.model';
import { RemoteData } from '../../../core/data/remote-data';
Expand Down Expand Up @@ -54,6 +60,7 @@ export class QaEventNotificationComponent implements OnChanges {

constructor(
private qualityAssuranceSourceDataService: QualityAssuranceSourceDataService,
private authorizationService: AuthorizationDataService,
) {}

/**
Expand All @@ -73,17 +80,24 @@ export class QaEventNotificationComponent implements OnChanges {
const findListTopicOptions: FindListOptions = {
searchParams: [new RequestParam('target', this.item.uuid)],
};
return this.qualityAssuranceSourceDataService.getSourcesByTarget(findListTopicOptions, false)
.pipe(
getFirstCompletedRemoteData(),
map((data: RemoteData<PaginatedList<QualityAssuranceSourceObject>>) => {
if (data.hasSucceeded) {
return data.payload.page;
}
return [];
}),
catchError(() => []),
);
// /api/integration/qualityassurancesources/search/byTarget answers 401 to anyone who is not
// allowed to see quality assurance events, so asking for it without checking the feature first
// produced a guaranteed error on every item page for every anonymous visitor. The notification
// this component renders is only actionable by a user who has that permission anyway.
return this.authorizationService.isAuthorized(FeatureID.CanSeeQA).pipe(
switchMap((canSeeQA: boolean) => canSeeQA
? this.qualityAssuranceSourceDataService.getSourcesByTarget(findListTopicOptions, false).pipe(
getFirstCompletedRemoteData(),
map((data: RemoteData<PaginatedList<QualityAssuranceSourceObject>>) => {
if (data.hasSucceeded) {
return data.payload.page;
}
return [];
}),
catchError(() => of([])),
)
: of([])),
);
}

/**
Expand Down
11 changes: 11 additions & 0 deletions src/app/shared/menu/providers/create-report.menu.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,4 +97,15 @@ describe('CreateReportMenuProvider', () => {
done();
});
});

it('should not read contentreport.enable when the user is not a site administrator', (done) => {
(authorizationServiceStub.isAuthorized as jasmine.Spy).and.returnValue(of(false));
(configurationDataService.findByPropertyName as jasmine.Spy).calls.reset();

provider.getTopSection().subscribe((section) => {
expect(configurationDataService.findByPropertyName).not.toHaveBeenCalled();
expect(section.visible).toBeFalse();
done();
});
});
});
53 changes: 32 additions & 21 deletions src/app/shared/menu/providers/create-report.menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@

import { Injectable } from '@angular/core';
import {
combineLatest as observableCombineLatest,
Observable,
of,
} from 'rxjs';
import { map } from 'rxjs/operators';
import {
map,
switchMap,
} from 'rxjs/operators';

import { ConfigurationDataService } from '../../../core/data/configuration-data.service';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
Expand All @@ -37,19 +40,33 @@ export class CreateReportMenuProvider extends AbstractExpandableMenuProvider {
super();
}

/**
* Whether the Reports menu should be shown at all: the user is a site administrator *and* the
* content report feature is switched on in the backend.
*
* The order matters. The authorization is checked first and the configuration is only fetched for
* an administrator, because /api/config/properties/contentreport.enable answers 404 whenever the
* property is not set — which is the default — and that request used to be fired on every page
* load for every visitor, including anonymous ones who can never see this menu.
*/
private isReportMenuAvailable(): Observable<boolean> {
return this.authorizationService.isAuthorized(FeatureID.AdministratorOf).pipe(
switchMap((isSiteAdmin: boolean) => isSiteAdmin
? this.configurationDataService.findByPropertyName('contentreport.enable').pipe(
getFirstCompletedRemoteData(),
map((res: RemoteData<ConfigurationProperty>) => res.hasSucceeded && res.payload && res.payload.values[0] === 'true'),
)
: of(false)),
);
}

getSubSections(): Observable<PartialMenuSection[]> {
return observableCombineLatest([
this.configurationDataService.findByPropertyName('contentreport.enable').pipe(
getFirstCompletedRemoteData(),
map((res: RemoteData<ConfigurationProperty>) => res.hasSucceeded && res.payload && res.payload.values[0] === 'true'),
),
this.authorizationService.isAuthorized(FeatureID.AdministratorOf),
]).pipe(
map(([reportEnabled, isSiteAdmin]: [boolean, boolean]) => {
return this.isReportMenuAvailable().pipe(
map((available: boolean) => {
return [
/* Collections Report */
{
visible: isSiteAdmin && reportEnabled,
visible: available,
model: {
type: MenuItemType.LINK,
text: 'menu.section.reports.collections',
Expand All @@ -59,7 +76,7 @@ export class CreateReportMenuProvider extends AbstractExpandableMenuProvider {
},
/* Queries Report */
{
visible: isSiteAdmin && reportEnabled,
visible: available,
model: {
type: MenuItemType.LINK,
text: 'menu.section.reports.queries',
Expand All @@ -72,16 +89,10 @@ export class CreateReportMenuProvider extends AbstractExpandableMenuProvider {
}

getTopSection(): Observable<PartialMenuSection> {
return observableCombineLatest([
this.configurationDataService.findByPropertyName('contentreport.enable').pipe(
getFirstCompletedRemoteData(),
map((res: RemoteData<ConfigurationProperty>) => res.hasSucceeded && res.payload && res.payload.values[0] === 'true'),
),
this.authorizationService.isAuthorized(FeatureID.AdministratorOf),
]).pipe(
map(([reportEnabled, isSiteAdmin]: [boolean, boolean]) => {
return this.isReportMenuAvailable().pipe(
map((available: boolean) => {
return {
visible: isSiteAdmin && reportEnabled,
visible: available,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.reports',
Expand Down
15 changes: 14 additions & 1 deletion src/app/shared/menu/providers/export.menu.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,17 +49,19 @@ describe('ExportMenuProvider', () => {

let provider: ExportMenuProvider;
let authorizationServiceStub = new AuthorizationDataServiceStub();
let scriptServiceStub: ScriptServiceStub;

beforeEach(() => {
spyOn(authorizationServiceStub, 'isAuthorized').and.returnValue(
of(true),
);
scriptServiceStub = new ScriptServiceStub();

TestBed.configureTestingModule({
providers: [
ExportMenuProvider,
{ provide: AuthorizationDataService, useValue: authorizationServiceStub },
{ provide: ScriptDataService, useClass: ScriptServiceStub },
{ provide: ScriptDataService, useValue: scriptServiceStub },
],
});
provider = TestBed.inject(ExportMenuProvider);
Expand All @@ -82,4 +84,15 @@ describe('ExportMenuProvider', () => {
done();
});
});

it('getSubSections should not query the script endpoint when the user is not an administrator', (done) => {
(authorizationServiceStub.isAuthorized as jasmine.Spy).and.returnValue(of(false));
spyOn(scriptServiceStub, 'scriptWithNameExistsAndCanExecute').and.callThrough();

provider.getSubSections().subscribe((sections) => {
expect(scriptServiceStub.scriptWithNameExistsAndCanExecute).not.toHaveBeenCalled();
expect(sections.every((section) => section.visible === false)).toBeTrue();
done();
});
});
});
Loading
Loading