Skip to content

Commit 79c4ef6

Browse files
committed
fix(ssr): added throttle token and updated config load
1 parent bd98668 commit 79c4ef6

13 files changed

Lines changed: 185 additions & 96 deletions

src/app/app.config.server.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,37 @@ import { ApplicationConfig, mergeApplicationConfig } from '@angular/core';
22
import { provideServerRendering } from '@angular/platform-server';
33
import { provideServerRouting } from '@angular/ssr';
44

5+
import { SSR_CONFIG } from '@core/constants/ssr-config.token';
6+
import { ConfigModel } from '@core/models/config.model';
7+
58
import { appConfig } from './app.config';
69
import { serverRoutes } from './app.routes.server';
710

11+
import { existsSync, readFileSync } from 'node:fs';
12+
import { dirname, resolve } from 'node:path';
13+
import { fileURLToPath } from 'node:url';
14+
15+
function loadSsrConfig(): ConfigModel {
16+
const serverDistFolder = dirname(fileURLToPath(import.meta.url));
17+
const configPath = resolve(serverDistFolder, '../browser/assets/config/config.json');
18+
19+
if (existsSync(configPath)) {
20+
try {
21+
return JSON.parse(readFileSync(configPath, 'utf-8'));
22+
} catch {
23+
return {} as ConfigModel;
24+
}
25+
}
26+
27+
return {} as ConfigModel;
28+
}
29+
830
const serverConfig: ApplicationConfig = {
9-
providers: [provideServerRendering(), provideServerRouting(serverRoutes)],
31+
providers: [
32+
provideServerRendering(),
33+
provideServerRouting(serverRoutes),
34+
{ provide: SSR_CONFIG, useFactory: loadSsrConfig },
35+
],
1036
};
1137

1238
export const config = mergeApplicationConfig(appConfig, serverConfig);
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { InjectionToken } from '@angular/core';
2+
3+
import { ConfigModel } from '@core/models/config.model';
4+
5+
export const SSR_CONFIG = new InjectionToken<ConfigModel>('SSR_CONFIG');

src/app/core/interceptors/auth.interceptor.spec.ts

Lines changed: 52 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,35 +4,32 @@ import { MockProvider } from 'ng-mocks';
44
import { of } from 'rxjs';
55

66
import { HttpRequest } from '@angular/common/http';
7-
import { runInInjectionContext } from '@angular/core';
7+
import { PLATFORM_ID, runInInjectionContext } from '@angular/core';
88
import { TestBed } from '@angular/core/testing';
99

10+
import { ENVIRONMENT } from '@core/provider/environment.provider';
11+
import { EnvironmentModel } from '@osf/shared/models/environment.model';
12+
1013
import { authInterceptor } from './auth.interceptor';
1114

1215
describe('authInterceptor', () => {
1316
let cookieService: CookieService;
1417
let mockHandler: jest.Mock;
1518

16-
beforeEach(() => {
17-
mockHandler = jest.fn();
18-
19+
const setup = (platformId = 'browser', environmentOverrides: Partial<EnvironmentModel> = {}) => {
1920
TestBed.configureTestingModule({
2021
providers: [
21-
MockProvider(CookieService, {
22-
get: jest.fn(),
23-
}),
24-
{
25-
provide: 'PLATFORM_ID',
26-
useValue: 'browser',
27-
},
28-
{
29-
provide: 'REQUEST',
30-
useValue: null,
31-
},
22+
MockProvider(CookieService, { get: jest.fn() }),
23+
MockProvider(PLATFORM_ID, platformId),
24+
MockProvider(ENVIRONMENT, { throttleToken: '', ...environmentOverrides } as EnvironmentModel),
3225
],
3326
});
3427

3528
cookieService = TestBed.inject(CookieService);
29+
};
30+
31+
beforeEach(() => {
32+
mockHandler = jest.fn();
3633
jest.clearAllMocks();
3734
});
3835

@@ -49,6 +46,7 @@ describe('authInterceptor', () => {
4946
};
5047

5148
it('should skip CrossRef funders API requests', () => {
49+
setup();
5250
const request = createRequest('/api.crossref.org/funders/10.13039/100000001');
5351
const handler = createHandler();
5452

@@ -60,6 +58,7 @@ describe('authInterceptor', () => {
6058
});
6159

6260
it('should set Accept header to */* for text response type', () => {
61+
setup();
6362
const request = createRequest('/api/v2/projects/', { responseType: 'text' });
6463
const handler = createHandler();
6564

@@ -71,6 +70,7 @@ describe('authInterceptor', () => {
7170
});
7271

7372
it('should set Accept header to API version for json response type', () => {
73+
setup();
7474
const request = createRequest('/api/v2/projects/', { responseType: 'json' });
7575
const handler = createHandler();
7676

@@ -82,6 +82,7 @@ describe('authInterceptor', () => {
8282
});
8383

8484
it('should set Content-Type header when not present', () => {
85+
setup();
8586
const request = createRequest('/api/v2/projects/');
8687
const handler = createHandler();
8788

@@ -93,6 +94,7 @@ describe('authInterceptor', () => {
9394
});
9495

9596
it('should not override existing Content-Type header', () => {
97+
setup();
9698
const request = createRequest('/api/v2/projects/');
9799
const requestWithHeaders = request.clone({
98100
setHeaders: { 'Content-Type': 'application/json' },
@@ -107,6 +109,7 @@ describe('authInterceptor', () => {
107109
});
108110

109111
it('should add CSRF token and withCredentials in browser platform', () => {
112+
setup();
110113
jest.spyOn(cookieService, 'get').mockReturnValue('csrf-token-123');
111114

112115
const request = createRequest('/api/v2/projects/');
@@ -122,6 +125,7 @@ describe('authInterceptor', () => {
122125
});
123126

124127
it('should not add CSRF token when not available in browser platform', () => {
128+
setup();
125129
jest.spyOn(cookieService, 'get').mockReturnValue('');
126130

127131
const request = createRequest('/api/v2/projects/');
@@ -135,4 +139,37 @@ describe('authInterceptor', () => {
135139
expect(modifiedRequest.headers.has('X-CSRFToken')).toBe(false);
136140
expect(modifiedRequest.withCredentials).toBe(true);
137141
});
142+
143+
it('should not add X-Throttle-Token on browser platform', () => {
144+
setup('browser', { throttleToken: 'test-token' });
145+
const request = createRequest('/api/v2/projects/');
146+
const handler = createHandler();
147+
148+
runInInjectionContext(TestBed, () => authInterceptor(request, handler));
149+
150+
const modifiedRequest = handler.mock.calls[0][0];
151+
expect(modifiedRequest.headers.has('X-Throttle-Token')).toBe(false);
152+
});
153+
154+
it('should add X-Throttle-Token on server platform when token is present', () => {
155+
setup('server', { throttleToken: 'test-token' });
156+
const request = createRequest('/api/v2/projects/');
157+
const handler = createHandler();
158+
159+
runInInjectionContext(TestBed, () => authInterceptor(request, handler));
160+
161+
const modifiedRequest = handler.mock.calls[0][0];
162+
expect(modifiedRequest.headers.get('X-Throttle-Token')).toBe('test-token');
163+
});
164+
165+
it('should not add X-Throttle-Token on server platform when token is empty', () => {
166+
setup('server', { throttleToken: '' });
167+
const request = createRequest('/api/v2/projects/');
168+
const handler = createHandler();
169+
170+
runInInjectionContext(TestBed, () => authInterceptor(request, handler));
171+
172+
const modifiedRequest = handler.mock.calls[0][0];
173+
expect(modifiedRequest.headers.has('X-Throttle-Token')).toBe(false);
174+
});
138175
});

src/app/core/interceptors/auth.interceptor.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@ import { CookieService } from 'ngx-cookie-service';
22

33
import { Observable } from 'rxjs';
44

5+
import { isPlatformServer } from '@angular/common';
56
import { HttpEvent, HttpHandlerFn, HttpInterceptorFn, HttpRequest } from '@angular/common/http';
6-
import { inject } from '@angular/core';
7+
import { inject, PLATFORM_ID } from '@angular/core';
8+
9+
import { ENVIRONMENT } from '@core/provider/environment.provider';
710

811
export const authInterceptor: HttpInterceptorFn = (
912
req: HttpRequest<unknown>,
@@ -13,6 +16,7 @@ export const authInterceptor: HttpInterceptorFn = (
1316
return next(req);
1417
}
1518

19+
const platformId = inject(PLATFORM_ID);
1620
const cookieService = inject(CookieService);
1721
const csrfToken = cookieService.get('api-csrf');
1822

@@ -28,6 +32,14 @@ export const authInterceptor: HttpInterceptorFn = (
2832
headers['X-CSRFToken'] = csrfToken;
2933
}
3034

35+
if (isPlatformServer(platformId)) {
36+
const environment = inject(ENVIRONMENT);
37+
38+
if (environment.throttleToken) {
39+
headers['X-Throttle-Token'] = environment.throttleToken;
40+
}
41+
}
42+
3143
const authReq = req.clone({ setHeaders: headers, withCredentials: true });
3244

3345
return next(authReq);
Lines changed: 78 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,65 +1,108 @@
1-
import { HttpTestingController } from '@angular/common/http/testing';
1+
import { MockProvider } from 'ng-mocks';
2+
3+
import { provideHttpClient } from '@angular/common/http';
4+
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
5+
import { PLATFORM_ID } from '@angular/core';
26
import { TestBed } from '@angular/core/testing';
37

8+
import { SSR_CONFIG } from '@core/constants/ssr-config.token';
49
import { ConfigModel } from '@core/models/config.model';
510
import { ENVIRONMENT } from '@core/provider/environment.provider';
611
import { EnvironmentModel } from '@osf/shared/models/environment.model';
712

813
import { OSFConfigService } from './osf-config.service';
914

10-
import { OSFTestingModule } from '@testing/osf.testing.module';
11-
12-
describe('Service: Config', () => {
15+
describe('OSFConfigService', () => {
1316
let service: OSFConfigService;
14-
let httpMock: HttpTestingController;
1517
let environment: EnvironmentModel;
1618

1719
const mockConfig: ConfigModel = {
20+
sentryDsn: 'https://sentry.example.com/123',
21+
googleTagManagerId: 'GTM-TEST',
22+
googleFilePickerApiKey: '',
23+
googleFilePickerAppId: 0,
1824
apiDomainUrl: 'https://api.example.com',
19-
production: true,
20-
} as any; // Cast to any if index signature isn’t added
25+
};
2126

22-
beforeEach(async () => {
23-
jest.clearAllMocks();
24-
await TestBed.configureTestingModule({
25-
imports: [OSFTestingModule],
26-
providers: [OSFConfigService],
27-
}).compileComponents();
27+
const setupBrowser = () => {
28+
TestBed.configureTestingModule({
29+
providers: [provideHttpClient(), provideHttpClientTesting(), MockProvider(PLATFORM_ID, 'browser')],
30+
});
2831

2932
service = TestBed.inject(OSFConfigService);
30-
httpMock = TestBed.inject(HttpTestingController);
3133
environment = TestBed.inject(ENVIRONMENT);
32-
});
34+
};
3335

34-
it('should return a value with get()', async () => {
35-
let loadPromise = service.load();
36-
const request = httpMock.expectOne('/assets/config/config.json');
37-
request.flush(mockConfig);
38-
await loadPromise;
39-
expect(environment.apiDomainUrl).toBe('https://api.example.com');
40-
expect(environment.production).toBeTruthy();
41-
loadPromise = service.load();
36+
const setupServer = (ssrConfig: ConfigModel | null = null) => {
37+
TestBed.configureTestingModule({
38+
providers: [
39+
provideHttpClient(),
40+
provideHttpClientTesting(),
41+
MockProvider(PLATFORM_ID, 'server'),
42+
...(ssrConfig ? [{ provide: SSR_CONFIG, useValue: ssrConfig }] : []),
43+
],
44+
});
45+
46+
service = TestBed.inject(OSFConfigService);
47+
environment = TestBed.inject(ENVIRONMENT);
48+
};
49+
50+
it('should load config via HTTP on browser and merge into ENVIRONMENT', async () => {
51+
setupBrowser();
52+
const httpMock = TestBed.inject(HttpTestingController);
53+
54+
const loadPromise = service.load();
55+
httpMock.expectOne('/assets/config/config.json').flush(mockConfig);
4256
await loadPromise;
4357

4458
expect(environment.apiDomainUrl).toBe('https://api.example.com');
45-
expect(environment.production).toBeTruthy();
46-
47-
expect(httpMock.verify()).toBeUndefined();
59+
expect(environment.sentryDsn).toBe('https://sentry.example.com/123');
60+
httpMock.verify();
4861
});
4962

50-
it('should return a value with ahs()', async () => {
51-
let loadPromise = service.load();
52-
const request = httpMock.expectOne('/assets/config/config.json');
53-
request.flush(mockConfig);
54-
await loadPromise;
63+
it('should only fetch config once on repeated load calls', async () => {
64+
setupBrowser();
65+
const httpMock = TestBed.inject(HttpTestingController);
66+
67+
const firstLoad = service.load();
68+
httpMock.expectOne('/assets/config/config.json').flush(mockConfig);
69+
await firstLoad;
70+
71+
await service.load();
72+
httpMock.expectNone('/assets/config/config.json');
73+
5574
expect(environment.apiDomainUrl).toBe('https://api.example.com');
56-
expect(environment.production).toBeTruthy();
75+
httpMock.verify();
76+
});
5777

58-
loadPromise = service.load();
78+
it('should fallback to empty config on HTTP error', async () => {
79+
setupBrowser();
80+
const httpMock = TestBed.inject(HttpTestingController);
81+
const originalUrl = environment.apiDomainUrl;
82+
83+
const loadPromise = service.load();
84+
httpMock.expectOne('/assets/config/config.json').error(new ProgressEvent('error'));
5985
await loadPromise;
86+
87+
expect(environment.apiDomainUrl).toBe(originalUrl);
88+
httpMock.verify();
89+
});
90+
91+
it('should load config from SSR_CONFIG on server and merge into ENVIRONMENT', async () => {
92+
setupServer(mockConfig);
93+
94+
await service.load();
95+
6096
expect(environment.apiDomainUrl).toBe('https://api.example.com');
61-
expect(environment.production).toBeTruthy();
97+
expect(environment.sentryDsn).toBe('https://sentry.example.com/123');
98+
});
99+
100+
it('should fallback to empty config on server when SSR_CONFIG is not provided', async () => {
101+
setupServer();
102+
const originalUrl = environment.apiDomainUrl;
103+
104+
await service.load();
62105

63-
expect(httpMock.verify()).toBeUndefined();
106+
expect(environment.apiDomainUrl).toBe(originalUrl);
64107
});
65108
});

0 commit comments

Comments
 (0)