Skip to content

Commit fc5a1f5

Browse files
milanmajchrakclaude
andcommitted
ZCU-PUB/test: reload redirect must be absolute and must not fire during SSR
Replicates dataquest-dev/dspace-customers#236: after a Shibboleth login the relative 'reload/<timestamp>' URL is resolved against the current URL (e.g. /bitstreams/<uuid>/download), producing growing /bitstreams/<uuid>/reload/reload/... URLs and a redirect loop, because during SSR the redirect repeats on every request (the dsRedirectUrl cookie cannot be cleared server-side - ServerCookieService.remove is a no-op). Also stops static-page.component.spec from mutating the shared environment object (it replaced environment.ui, breaking later specs reading environment.ui.nameSpace). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 7c15413 commit fc5a1f5

5 files changed

Lines changed: 141 additions & 13 deletions

File tree

src/app/core/auth/auth.effects.spec.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { fakeAsync, TestBed, tick } from '@angular/core/testing';
2+
import { NgZone } from '@angular/core';
23

4+
import { Actions } from '@ngrx/effects';
35
import { provideMockActions } from '@ngrx/effects/testing';
46
import { Store, StoreModule } from '@ngrx/store';
57
import { MockStore, provideMockStore } from '@ngrx/store/testing';
@@ -17,6 +19,7 @@ import {
1719
CheckAuthenticationTokenCookieAction,
1820
LogOutErrorAction,
1921
LogOutSuccessAction,
22+
RedirectAfterLoginSuccessAction,
2023
RefreshTokenErrorAction,
2124
RefreshTokenSuccessAction,
2225
RetrieveAuthenticatedEpersonAction,
@@ -176,6 +179,52 @@ describe('AuthEffects', () => {
176179
done();
177180
});
178181

182+
describe('when a redirect url is set', () => {
183+
const redirectUrl = '/bitstreams/eb0ca4e9-3e00-4c1f-8b19-a2c9f4f01234/download';
184+
185+
beforeEach(() => {
186+
spyOn((authEffects as any).authService, 'storeToken');
187+
authServiceStub.setRedirectUrl(redirectUrl);
188+
actions = hot('--a-', {
189+
a: {
190+
type: AuthActionTypes.AUTHENTICATED_SUCCESS, payload: {
191+
authenticated: true,
192+
authToken: token,
193+
userHref: EPersonMock._links.self.href
194+
}
195+
}
196+
});
197+
});
198+
199+
afterEach(() => {
200+
authServiceStub.setRedirectUrl(undefined);
201+
});
202+
203+
it('should return a REDIRECT_AFTER_LOGIN_SUCCESS action in the browser', () => {
204+
const expected = cold('--b-', { b: new RedirectAfterLoginSuccessAction(redirectUrl) });
205+
206+
expect(authEffects.authenticatedSuccess$).toBeObservable(expected);
207+
});
208+
209+
it('should return a RETRIEVE_AUTHENTICATED_EPERSON action during server-side rendering', () => {
210+
// The server cannot clear the redirect cookie (ServerCookieService.set/remove are no-ops),
211+
// so a hard redirect during SSR would repeat on every request and create a redirect loop.
212+
// The redirect must be left to the browser.
213+
const serverAuthEffects: AuthEffects = new (AuthEffects as any)(
214+
TestBed.inject(Actions),
215+
TestBed.inject(NgZone),
216+
authorizationService,
217+
authServiceStub as any,
218+
TestBed.inject(Store) as any,
219+
'server'
220+
);
221+
222+
const expected = cold('--b-', { b: new RetrieveAuthenticatedEpersonAction(EPersonMock._links.self.href) });
223+
224+
expect(serverAuthEffects.authenticatedSuccess$).toBeObservable(expected);
225+
});
226+
});
227+
179228
});
180229

181230
describe('checkToken$', () => {

src/app/core/auth/auth.service.spec.ts

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { NotificationsServiceStub } from '../../shared/testing/notifications-ser
3434
import { SetUserAsIdleAction, UnsetUserAsIdleAction } from './auth.actions';
3535
import { SpecialGroupDataMock, SpecialGroupDataMock$ } from '../../shared/testing/special-group.mock';
3636
import { cold } from 'jasmine-marbles';
37+
import { environment } from '../../../environments/environment';
3738

3839
describe('AuthService test', () => {
3940

@@ -103,7 +104,7 @@ describe('AuthService test', () => {
103104
linkService = {
104105
resolveLinks: {}
105106
};
106-
hardRedirectService = jasmine.createSpyObj('hardRedirectService', ['redirect']);
107+
hardRedirectService = jasmine.createSpyObj('hardRedirectService', ['redirect', 'getCurrentRoute']);
107108
spyOn(linkService, 'resolveLinks').and.returnValue({ authenticated: true, eperson: observableOf({ payload: {} }) });
108109

109110
}
@@ -374,28 +375,39 @@ describe('AuthService test', () => {
374375
expect(storage.remove).toHaveBeenCalled();
375376
});
376377

377-
it('should redirect to reload with redirect url', () => {
378+
// The reload URL must be absolute (nameSpace-aware): a relative 'reload/...' URL is resolved
379+
// against the current URL - e.g. against /bitstreams/<uuid>/download after an external
380+
// (Shibboleth) login - producing invalid nested URLs like /bitstreams/<uuid>/reload/reload/...
381+
const reloadPrefix = environment.ui.nameSpace.replace(/\/$/, '') + '/reload/';
382+
383+
it('should redirect to the absolute reload URL with redirect url', () => {
378384
authService.navigateToRedirectUrl('/collection/123');
379385
// Reload with redirect URL set to /collection/123
380-
expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('reload/[0-9]*\\?redirect=' + encodeURIComponent('/collection/123'))));
386+
expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('^' + reloadPrefix + '[0-9]+\\?redirect=' + encodeURIComponent('/collection/123') + '$')));
381387
});
382388

383-
it('should redirect to reload with /home', () => {
389+
it('should redirect to the absolute reload URL with /home', () => {
384390
authService.navigateToRedirectUrl('/home');
385391
// Reload with redirect URL set to /home
386-
expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('reload/[0-9]*\\?redirect=' + encodeURIComponent('/home'))));
392+
expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('^' + reloadPrefix + '[0-9]+\\?redirect=' + encodeURIComponent('/home') + '$')));
387393
});
388394

389-
it('should redirect to regular reload and not to /login', () => {
395+
it('should redirect to the absolute reload URL and not to /login', () => {
390396
authService.navigateToRedirectUrl('/login');
391397
// Reload without a redirect URL
392-
expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('reload/[0-9]*(?!\\?)$')));
398+
expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('^' + reloadPrefix + '[0-9]+$')));
393399
});
394400

395-
it('should redirect to regular reload when no redirect url is found', () => {
401+
it('should redirect to the absolute reload URL when no redirect url is found', () => {
396402
authService.navigateToRedirectUrl(undefined);
397403
// Reload without a redirect URL
398-
expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('reload/[0-9]*(?!\\?)$')));
404+
expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('^' + reloadPrefix + '[0-9]+$')));
405+
});
406+
407+
it('should not redirect again when the current route is already the reload page', () => {
408+
hardRedirectService.getCurrentRoute.and.returnValue(reloadPrefix + '123456789?redirect=' + encodeURIComponent('/home'));
409+
authService.navigateToRedirectUrl('/collection/123');
410+
expect(hardRedirectService.redirect).not.toHaveBeenCalled();
399411
});
400412

401413
describe('impersonate', () => {

src/app/core/locale/locale.service.spec.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1-
import { TestBed, waitForAsync } from '@angular/core/testing';
1+
import { fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing';
22

33
import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core';
4+
import { of } from 'rxjs';
5+
6+
import { environment } from '../../../environments/environment';
47

58
import { CookieService } from '../services/cookie.service';
69
import { CookieServiceMock } from '../../shared/mocks/cookie.service.mock';
@@ -147,4 +150,37 @@ describe('LocaleService test suite', () => {
147150
expect(service.getLanguageCodeList).toHaveBeenCalled();
148151
});
149152
});
153+
154+
describe('refreshAfterChangeLanguage', () => {
155+
let originalUi;
156+
let originalGetCurrentUrl;
157+
158+
beforeEach(() => {
159+
// Pin the tested nameSpace value: environment is a shared mutable object and some specs
160+
// replace environment.ui entirely, so this test must not rely on the suite order.
161+
originalUi = (environment as any).ui;
162+
(environment as any).ui = Object.assign({}, originalUi, { nameSpace: '/angular-dspace' });
163+
originalGetCurrentUrl = (routeService as any).getCurrentUrl;
164+
});
165+
166+
afterEach(() => {
167+
(environment as any).ui = originalUi;
168+
// routeServiceStub is a shared module-level singleton - restore it
169+
(routeService as any).getCurrentUrl = originalGetCurrentUrl;
170+
});
171+
172+
it('should hard redirect to the absolute (nameSpace-aware) reload URL', fakeAsync(() => {
173+
// A relative 'reload/...' URL would be resolved against the current URL
174+
// (e.g. /items/<uuid>) and produce an invalid nested URL like /items/<uuid>/reload/...
175+
const currentUrl = '/items/1234';
176+
const fakeLocation = { href: '' };
177+
serviceAsAny._window = { nativeWindow: { location: fakeLocation } };
178+
(routeService as any).getCurrentUrl = jasmine.createSpy('getCurrentUrl').and.returnValue(of(currentUrl));
179+
180+
service.refreshAfterChangeLanguage();
181+
tick();
182+
183+
expect(fakeLocation.href).toMatch(new RegExp('^/angular-dspace/reload/[0-9]+\\?redirect=' + encodeURIComponent(currentUrl) + '$'));
184+
}));
185+
});
150186
});

src/app/core/services/server-hard-redirect.service.spec.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ describe('ServerHardRedirectService', () => {
2323
});
2424

2525
describe('when performing a default redirect', () => {
26-
const redirect = 'test redirect';
26+
const redirect = '/test/redirect';
2727

2828
beforeEach(() => {
2929
service.redirect(redirect);
@@ -36,7 +36,7 @@ describe('ServerHardRedirectService', () => {
3636
});
3737

3838
describe('when performing a 301 redirect', () => {
39-
const redirect = 'test 301 redirect';
39+
const redirect = '/test/301/redirect';
4040
const redirectStatusCode = 301;
4141

4242
beforeEach(() => {
@@ -49,6 +49,35 @@ describe('ServerHardRedirectService', () => {
4949
});
5050
});
5151

52+
describe('when performing a redirect to a relative url', () => {
53+
// A relative URL in the Location header is resolved by the browser against the request URL,
54+
// e.g. 'reload/123' requested at /bitstreams/<uuid>/download resolves
55+
// to /bitstreams/<uuid>/reload/123 - such a redirect must never be emitted
56+
const redirect = 'reload/123456789';
57+
58+
beforeEach(() => {
59+
service.redirect(redirect);
60+
});
61+
62+
it('should redirect to the URL prefixed with a slash', () => {
63+
expect(mockResponse.redirect).toHaveBeenCalledWith(302, '/' + redirect);
64+
expect(mockResponse.end).toHaveBeenCalled();
65+
});
66+
});
67+
68+
describe('when performing a redirect to an external url', () => {
69+
const redirect = 'https://external-host.com/path';
70+
71+
beforeEach(() => {
72+
service.redirect(redirect);
73+
});
74+
75+
it('should redirect to the unchanged external URL', () => {
76+
expect(mockResponse.redirect).toHaveBeenCalledWith(302, redirect);
77+
expect(mockResponse.end).toHaveBeenCalled();
78+
});
79+
});
80+
5281
describe('when requesting the current route', () => {
5382

5483
beforeEach(() => {

src/app/static-page/static-page.component.spec.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@ describe('StaticPageComponent', () => {
2727
getCurrentLanguageCode: jasmine.createSpy('getCurrentLanguageCode'),
2828
});
2929

30-
appConfig = Object.assign(environment, {
30+
// Do not mutate the shared `environment` object - replacing `environment.ui` would
31+
// break any later spec that reads e.g. environment.ui.nameSpace
32+
appConfig = Object.assign({}, environment, {
3133
ui: {
3234
namespace: 'testNamespace'
3335
}

0 commit comments

Comments
 (0)