Skip to content

Commit 555fe32

Browse files
dsteelma-umdPerplexity AI
andcommitted
LIBDRUM-1005. "Restricted Access" pages return 401/403 to bots
Corrects Google Search reports of "Soft 404" errors for the "Restricted Access" pages returned when a DSpace bitstream is embargoed or otherwise not available but returning either "401 Unauthorized" for anonymous users, or "403 Forbidden" for unauthorized users. This change only affects the Angular server-side rendering (SSR) functionality that is triggered by bots -- regular users will still see a "Restricted Access" page with a 200 OK response. Added unit tests to verify behavior. Co-authored-by: Perplexity AI <noreply@perplexity.ai> https://umd-dit.atlassian.net/browse/LIBDRUM-1005
1 parent bdf5bfb commit 555fe32

2 files changed

Lines changed: 341 additions & 4 deletions

File tree

Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,331 @@
1+
import {
2+
DatePipe,
3+
Location,
4+
} from '@angular/common';
5+
import {
6+
ComponentFixture,
7+
TestBed,
8+
waitForAsync,
9+
} from '@angular/core/testing';
10+
import {
11+
ActivatedRoute,
12+
Router,
13+
} from '@angular/router';
14+
import { TranslateModule } from '@ngx-translate/core';
15+
import { of as observableOf } from 'rxjs';
16+
17+
import { AuthService } from '../core/auth/auth.service';
18+
import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service';
19+
import { HardRedirectService } from '../core/services/hard-redirect.service';
20+
import { ServerResponseService } from '../core/services/server-response.service';
21+
import { Bitstream } from '../core/shared/bitstream.model';
22+
import { FileService } from '../core/shared/file.service';
23+
import { createSuccessfulRemoteDataObject } from '../shared/remote-data.utils';
24+
import { RestrictedAccessComponent } from './restricted-access.component';
25+
26+
describe('RestrictedAccessComponent', () => {
27+
let component: RestrictedAccessComponent;
28+
let fixture: ComponentFixture<RestrictedAccessComponent>;
29+
30+
let authService: jasmine.SpyObj<AuthService>;
31+
let authorizationService: jasmine.SpyObj<AuthorizationDataService>;
32+
let fileService: jasmine.SpyObj<FileService>;
33+
let hardRedirectService: jasmine.SpyObj<HardRedirectService>;
34+
let serverResponseService: jasmine.SpyObj<ServerResponseService>;
35+
let router: jasmine.SpyObj<Router>;
36+
let location: jasmine.SpyObj<Location>;
37+
let activatedRoute;
38+
39+
let bitstream: Bitstream;
40+
41+
function initBitstream(overrides: Partial<Bitstream> = {}): Bitstream {
42+
return Object.assign(new Bitstream(), {
43+
uuid: 'test-bitstream-uuid',
44+
metadata: {
45+
'dc.title': [{ value: 'test-file.pdf', language: null, authority: null, confidence: -1, place: 0 }],
46+
},
47+
_links: {
48+
content: { href: 'bitstream-content-link' },
49+
self: { href: 'bitstream-self-link' },
50+
},
51+
// Default in tests to "FOREVER" embargo
52+
embargoRestriction: 'FOREVER',
53+
...overrides,
54+
});
55+
}
56+
57+
function init(bitstreamOverrides: Partial<Bitstream> = {}) {
58+
bitstream = initBitstream(bitstreamOverrides);
59+
60+
authService = jasmine.createSpyObj('AuthService', {
61+
isAuthenticated: observableOf(false),
62+
setRedirectUrl: {},
63+
});
64+
65+
authorizationService = jasmine.createSpyObj('AuthorizationDataService', {
66+
isAuthorized: observableOf(false),
67+
});
68+
69+
fileService = jasmine.createSpyObj('FileService', {
70+
retrieveFileDownloadLink: observableOf('content-url-with-headers'),
71+
});
72+
73+
hardRedirectService = jasmine.createSpyObj('HardRedirectService', {
74+
redirect: {},
75+
});
76+
77+
serverResponseService = jasmine.createSpyObj('ServerResponseService', {
78+
setUnauthorized: {},
79+
setForbidden: {},
80+
setNotFound: {},
81+
setStatus: {},
82+
});
83+
84+
router = jasmine.createSpyObj('Router', ['navigateByUrl']);
85+
// Provide a url property for redirectOn4xx
86+
(router as any).url = '/restricted-access/test-bitstream-uuid';
87+
88+
location = jasmine.createSpyObj('Location', ['back']);
89+
90+
activatedRoute = {
91+
data: observableOf({
92+
bitstream: createSuccessfulRemoteDataObject(bitstream),
93+
}),
94+
};
95+
}
96+
97+
function initTestBed() {
98+
TestBed.configureTestingModule({
99+
imports: [
100+
TranslateModule.forRoot(),
101+
RestrictedAccessComponent,
102+
],
103+
providers: [
104+
{ provide: ActivatedRoute, useValue: activatedRoute },
105+
{ provide: Router, useValue: router },
106+
{ provide: AuthorizationDataService, useValue: authorizationService },
107+
{ provide: AuthService, useValue: authService },
108+
{ provide: FileService, useValue: fileService },
109+
{ provide: HardRedirectService, useValue: hardRedirectService },
110+
{ provide: ServerResponseService, useValue: serverResponseService },
111+
{ provide: Location, useValue: location },
112+
DatePipe,
113+
],
114+
}).compileComponents();
115+
}
116+
117+
// Helper function for setting up anonymous tests with a specific embargo
118+
// restriction
119+
function setupAnonymous(bitstreamOverrides) {
120+
beforeEach(waitForAsync(() => {
121+
init(bitstreamOverrides);
122+
(authService.isAuthenticated as jasmine.Spy).and.returnValue(observableOf(false));
123+
(authorizationService.isAuthorized as jasmine.Spy).and.returnValue(observableOf(false));
124+
initTestBed();
125+
}));
126+
127+
beforeEach(() => {
128+
fixture = TestBed.createComponent(RestrictedAccessComponent);
129+
component = fixture.componentInstance;
130+
fixture.detectChanges();
131+
});
132+
}
133+
134+
// Helper function verifying that a HTTP 401 Unauthorized status code is
135+
// set, and that a redirect to the bitstream is not performed.
136+
function verify401StatusCodeAndNoRedirectToDownload() {
137+
it('should set 401 Unauthorized and not redirect to the file', waitForAsync(() => {
138+
fixture.whenStable().then(() => {
139+
expect(serverResponseService.setUnauthorized).toHaveBeenCalled();
140+
});
141+
fixture.whenStable().then(() => {
142+
expect(serverResponseService.setForbidden).not.toHaveBeenCalled();
143+
});
144+
fixture.whenStable().then(() => {
145+
expect(hardRedirectService.redirect).not.toHaveBeenCalled();
146+
});
147+
}));
148+
}
149+
150+
describe('when the user is anonymous (not logged in)', () => {
151+
describe('when embargoRestriction is FOREVER', () => {
152+
setupAnonymous({ embargoRestriction: 'FOREVER' });
153+
154+
verify401StatusCodeAndNoRedirectToDownload();
155+
156+
it('should set the restrictedAccessMessage indicating the file is embargoed forever', waitForAsync(() => {
157+
fixture.whenStable().then(() => {
158+
expect(component.restrictedAccessMessage.value).toBe('bitstream.restricted-access.embargo.forever.message');
159+
});
160+
}));
161+
});
162+
163+
describe('when there is an embargo end date', () => {
164+
setupAnonymous({ embargoRestriction: '2199-04-08' });
165+
166+
verify401StatusCodeAndNoRedirectToDownload();
167+
168+
it('should set the restrictedAccessMessage indicating an end date', waitForAsync(() => {
169+
fixture.whenStable().then(() => {
170+
expect(component.restrictedAccessMessage.value).toBe(
171+
'bitstream.restricted-access.embargo.restricted-until.message');
172+
});
173+
}));
174+
});
175+
176+
describe('when embargoRestriction is NONE (embargo over, but file is restricted for another reason)', () => {
177+
setupAnonymous({ embargoRestriction: 'NONE' });
178+
179+
verify401StatusCodeAndNoRedirectToDownload();
180+
181+
it('should set the restrictedAccessMessage to a simple "forbidden" message', waitForAsync(() => {
182+
fixture.whenStable().then(() => {
183+
expect(component.restrictedAccessMessage.value).toBe('bitstream.restricted-access.anonymous.forbidden.message');
184+
});
185+
}));
186+
});
187+
188+
describe('when file is restricted for non-embargo reasons (such as Campus IP restriction)', () => {
189+
setupAnonymous({ embargoRestriction:null });
190+
191+
verify401StatusCodeAndNoRedirectToDownload();
192+
193+
it('should set the restrictedAccessMessage to a simple "forbidden" message', waitForAsync(() => {
194+
fixture.whenStable().then(() => {
195+
expect(component.restrictedAccessMessage.value).toBe('bitstream.restricted-access.anonymous.forbidden.message');
196+
});
197+
}));
198+
});
199+
200+
describe('when the user is authorized (even if there is an embargo)', () => {
201+
beforeEach(waitForAsync(() => {
202+
init();
203+
(authService.isAuthenticated as jasmine.Spy).and.returnValue(observableOf(false));
204+
(authorizationService.isAuthorized as jasmine.Spy).and.returnValue(observableOf(true));
205+
initTestBed();
206+
}));
207+
208+
beforeEach(() => {
209+
fixture = TestBed.createComponent(RestrictedAccessComponent);
210+
component = fixture.componentInstance;
211+
fixture.detectChanges();
212+
});
213+
214+
it('should redirect to the content link', waitForAsync(() => {
215+
fixture.whenStable().then(() => {
216+
expect(hardRedirectService.redirect).toHaveBeenCalled();
217+
});
218+
}));
219+
220+
it('should NOT call setUnauthorized', waitForAsync(() => {
221+
fixture.whenStable().then(() => {
222+
expect(serverResponseService.setUnauthorized).not.toHaveBeenCalled();
223+
});
224+
}));
225+
226+
it('should NOT call setForbidden', waitForAsync(() => {
227+
fixture.whenStable().then(() => {
228+
expect(serverResponseService.setForbidden).not.toHaveBeenCalled();
229+
});
230+
}));
231+
});
232+
});
233+
234+
describe('when the user is logged in', () => {
235+
describe('returns 403 Forbidden when the user is not authorized to access the file', () => {
236+
beforeEach(waitForAsync(() => {
237+
init();
238+
(authService.isAuthenticated as jasmine.Spy).and.returnValue(observableOf(true));
239+
(authorizationService.isAuthorized as jasmine.Spy).and.returnValue(observableOf(false));
240+
initTestBed();
241+
}));
242+
243+
beforeEach(() => {
244+
fixture = TestBed.createComponent(RestrictedAccessComponent);
245+
component = fixture.componentInstance;
246+
fixture.detectChanges();
247+
});
248+
249+
it('should call setForbidden on ServerResponseService', waitForAsync(() => {
250+
fixture.whenStable().then(() => {
251+
expect(serverResponseService.setForbidden).toHaveBeenCalled();
252+
});
253+
}));
254+
255+
it('should NOT call setUnauthorized on ServerResponseService', waitForAsync(() => {
256+
fixture.whenStable().then(() => {
257+
expect(serverResponseService.setUnauthorized).not.toHaveBeenCalled();
258+
});
259+
}));
260+
261+
it('should NOT redirect to a download', waitForAsync(() => {
262+
fixture.whenStable().then(() => {
263+
expect(hardRedirectService.redirect).not.toHaveBeenCalled();
264+
});
265+
}));
266+
267+
it('should set the restrictedAccessHeader', waitForAsync(() => {
268+
fixture.whenStable().then(() => {
269+
expect(component.restrictedAccessHeader.value).toBe('bitstream.restricted-access.user.forbidden.header');
270+
});
271+
}));
272+
it('should set the restrictedAccessMessage', waitForAsync(() => {
273+
fixture.whenStable().then(() => {
274+
expect(component.restrictedAccessMessage.value).toBe(
275+
'bitstream.restricted-access.user.forbidden.with_file.message');
276+
});
277+
}));
278+
});
279+
280+
describe('allows access to the file when the user is authorized', () => {
281+
beforeEach(waitForAsync(() => {
282+
init();
283+
(authService.isAuthenticated as jasmine.Spy).and.returnValue(observableOf(true));
284+
(authorizationService.isAuthorized as jasmine.Spy).and.returnValue(observableOf(true));
285+
initTestBed();
286+
}));
287+
288+
beforeEach(() => {
289+
fixture = TestBed.createComponent(RestrictedAccessComponent);
290+
component = fixture.componentInstance;
291+
fixture.detectChanges();
292+
});
293+
294+
it('should NOT call setUnauthorized', waitForAsync(() => {
295+
fixture.whenStable().then(() => {
296+
expect(serverResponseService.setUnauthorized).not.toHaveBeenCalled();
297+
});
298+
}));
299+
300+
it('should NOT call setForbidden', waitForAsync(() => {
301+
fixture.whenStable().then(() => {
302+
expect(serverResponseService.setForbidden).not.toHaveBeenCalled();
303+
});
304+
}));
305+
306+
it('should redirect to the file download link', waitForAsync(() => {
307+
fixture.whenStable().then(() => {
308+
expect(hardRedirectService.redirect).toHaveBeenCalledWith('content-url-with-headers');
309+
});
310+
}));
311+
});
312+
});
313+
314+
describe('back()', () => {
315+
beforeEach(waitForAsync(() => {
316+
init();
317+
initTestBed();
318+
}));
319+
320+
beforeEach(() => {
321+
fixture = TestBed.createComponent(RestrictedAccessComponent);
322+
component = fixture.componentInstance;
323+
fixture.detectChanges();
324+
});
325+
326+
it('should call location.back()', () => {
327+
component.back();
328+
expect(location.back).toHaveBeenCalled();
329+
});
330+
});
331+
});

src/app/restricted-access/restricted-access.component.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { AuthorizationDataService } from '../core/data/feature-authorization/aut
3232
import { FeatureID } from '../core/data/feature-authorization/feature-id';
3333
import { RemoteData } from '../core/data/remote-data';
3434
import { HardRedirectService } from '../core/services/hard-redirect.service';
35+
import { ServerResponseService } from '../core/services/server-response.service';
3536
import { redirectOn4xx } from '../core/shared/authorized.operators';
3637
import { Bitstream } from '../core/shared/bitstream.model';
3738
import { FileService } from '../core/shared/file.service';
@@ -42,7 +43,7 @@ import {
4243
} from '../shared/empty.util';
4344

4445
/**
45-
* This component representing the `Restricted Access` DSpace page.
46+
* This component represents the `Restricted Access` DSpace page.
4647
*/
4748
@Component({
4849
selector: 'ds-restricted-access',
@@ -77,6 +78,7 @@ export class RestrictedAccessComponent implements OnInit {
7778
private translateService: TranslateService,
7879
private datePipe: DatePipe,
7980
private location: Location,
81+
private responseService: ServerResponseService,
8082
) {
8183
}
8284

@@ -124,6 +126,8 @@ export class RestrictedAccessComponent implements OnInit {
124126

125127
if (isLoggedIn) {
126128
// This is a logged in user
129+
// Set 403 Forbidden response status code for logged-in users without download permission
130+
this.responseService.setForbidden();
127131
header$ = this.translateService.get('bitstream.restricted-access.user.forbidden.header', {});
128132

129133
if (bitstream && bitstream.metadata['dc.title'] && bitstream.metadata['dc.title'][0] && bitstream.metadata['dc.title'][0].value) {
@@ -136,6 +140,8 @@ export class RestrictedAccessComponent implements OnInit {
136140
}
137141
} else {
138142
// This is an anonymous user
143+
// Set 401 Unauthorized response status code for anonymous users
144+
this.responseService.setUnauthorized();
139145
[header$, message$] = this.configureAnonymous(bitstream);
140146
}
141147

@@ -164,18 +170,18 @@ export class RestrictedAccessComponent implements OnInit {
164170
);
165171
} else {
166172
// Reach this branch when embargoRestriction is "NONE", but there is some
167-
// other restriction, such as a "Campus" IP address group restiction.
173+
// other restriction, such as a "Campus" IP address group restriction.
168174
message$ = this.translateService.get('bitstream.restricted-access.anonymous.forbidden.message', {});
169175
}
170176

171177
return [header$, message$];
172178
}
173179

174180
/**
175-
* Returns true if the given String represents a valid date, false otherise.
181+
* Returns true if the given String represents a valid date, false otherwise.
176182
*
177183
* @param str the String to check.
178-
* @true if the given String represents a valid date, false otherise.
184+
* @returns true if the given String represents a valid date, false otherwise.
179185
*/
180186
private isValidDate(str: string): boolean {
181187
// Expected date is in yyyy-MM-dd format.

0 commit comments

Comments
 (0)