Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
28 changes: 27 additions & 1 deletion src/app/app.config.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,37 @@ import { ApplicationConfig, mergeApplicationConfig } from '@angular/core';
import { provideServerRendering } from '@angular/platform-server';
import { provideServerRouting } from '@angular/ssr';

import { SSR_CONFIG } from '@core/constants/ssr-config.token';
import { ConfigModel } from '@core/models/config.model';

import { appConfig } from './app.config';
import { serverRoutes } from './app.routes.server';

import { existsSync, readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

function loadSsrConfig(): ConfigModel {
const serverDistFolder = dirname(fileURLToPath(import.meta.url));
const configPath = resolve(serverDistFolder, '../browser/assets/config/config.json');

if (existsSync(configPath)) {
try {
return JSON.parse(readFileSync(configPath, 'utf-8'));
} catch {
return {} as ConfigModel;
}
}

return {} as ConfigModel;
}

const serverConfig: ApplicationConfig = {
providers: [provideServerRendering(), provideServerRouting(serverRoutes)],
providers: [
provideServerRendering(),
provideServerRouting(serverRoutes),
{ provide: SSR_CONFIG, useFactory: loadSsrConfig },
],
};

export const config = mergeApplicationConfig(appConfig, serverConfig);
5 changes: 5 additions & 0 deletions src/app/core/constants/ssr-config.token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { InjectionToken } from '@angular/core';

import { ConfigModel } from '@core/models/config.model';

export const SSR_CONFIG = new InjectionToken<ConfigModel>('SSR_CONFIG');
67 changes: 52 additions & 15 deletions src/app/core/interceptors/auth.interceptor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,35 +4,32 @@ import { MockProvider } from 'ng-mocks';
import { of } from 'rxjs';

import { HttpRequest } from '@angular/common/http';
import { runInInjectionContext } from '@angular/core';
import { PLATFORM_ID, runInInjectionContext } from '@angular/core';
import { TestBed } from '@angular/core/testing';

import { ENVIRONMENT } from '@core/provider/environment.provider';
import { EnvironmentModel } from '@osf/shared/models/environment.model';

import { authInterceptor } from './auth.interceptor';

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

beforeEach(() => {
mockHandler = jest.fn();

const setup = (platformId = 'browser', environmentOverrides: Partial<EnvironmentModel> = {}) => {
TestBed.configureTestingModule({
providers: [
MockProvider(CookieService, {
get: jest.fn(),
}),
{
provide: 'PLATFORM_ID',
useValue: 'browser',
},
{
provide: 'REQUEST',
useValue: null,
},
MockProvider(CookieService, { get: jest.fn() }),
MockProvider(PLATFORM_ID, platformId),
MockProvider(ENVIRONMENT, { throttleToken: '', ...environmentOverrides } as EnvironmentModel),
],
});

cookieService = TestBed.inject(CookieService);
};

beforeEach(() => {
mockHandler = jest.fn();
jest.clearAllMocks();
});

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

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

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

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

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

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

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

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

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

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

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

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

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

const request = createRequest('/api/v2/projects/');
Expand All @@ -135,4 +139,37 @@ describe('authInterceptor', () => {
expect(modifiedRequest.headers.has('X-CSRFToken')).toBe(false);
expect(modifiedRequest.withCredentials).toBe(true);
});

it('should not add X-Throttle-Token on browser platform', () => {
setup('browser', { throttleToken: 'test-token' });
const request = createRequest('/api/v2/projects/');
const handler = createHandler();

runInInjectionContext(TestBed, () => authInterceptor(request, handler));

const modifiedRequest = handler.mock.calls[0][0];
expect(modifiedRequest.headers.has('X-Throttle-Token')).toBe(false);
});

it('should add X-Throttle-Token on server platform when token is present', () => {
setup('server', { throttleToken: 'test-token' });
const request = createRequest('/api/v2/projects/');
const handler = createHandler();

runInInjectionContext(TestBed, () => authInterceptor(request, handler));

const modifiedRequest = handler.mock.calls[0][0];
expect(modifiedRequest.headers.get('X-Throttle-Token')).toBe('test-token');
});

it('should not add X-Throttle-Token on server platform when token is empty', () => {
setup('server', { throttleToken: '' });
const request = createRequest('/api/v2/projects/');
const handler = createHandler();

runInInjectionContext(TestBed, () => authInterceptor(request, handler));

const modifiedRequest = handler.mock.calls[0][0];
expect(modifiedRequest.headers.has('X-Throttle-Token')).toBe(false);
});
});
14 changes: 13 additions & 1 deletion src/app/core/interceptors/auth.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ import { CookieService } from 'ngx-cookie-service';

import { Observable } from 'rxjs';

import { isPlatformServer } from '@angular/common';
import { HttpEvent, HttpHandlerFn, HttpInterceptorFn, HttpRequest } from '@angular/common/http';
import { inject } from '@angular/core';
import { inject, PLATFORM_ID } from '@angular/core';

import { ENVIRONMENT } from '@core/provider/environment.provider';

export const authInterceptor: HttpInterceptorFn = (
req: HttpRequest<unknown>,
Expand All @@ -13,6 +16,7 @@ export const authInterceptor: HttpInterceptorFn = (
return next(req);
}

const platformId = inject(PLATFORM_ID);
const cookieService = inject(CookieService);
const csrfToken = cookieService.get('api-csrf');

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

if (isPlatformServer(platformId)) {
const environment = inject(ENVIRONMENT);

if (environment.throttleToken) {
Comment thread
nsemets marked this conversation as resolved.
headers['X-Throttle-Token'] = environment.throttleToken;
}
}

const authReq = req.clone({ setHeaders: headers, withCredentials: true });

return next(authReq);
Expand Down
113 changes: 78 additions & 35 deletions src/app/core/services/osf-config.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,65 +1,108 @@
import { HttpTestingController } from '@angular/common/http/testing';
import { MockProvider } from 'ng-mocks';

import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { PLATFORM_ID } from '@angular/core';
import { TestBed } from '@angular/core/testing';

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

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

import { OSFTestingModule } from '@testing/osf.testing.module';

describe('Service: Config', () => {
describe('OSFConfigService', () => {
let service: OSFConfigService;
let httpMock: HttpTestingController;
let environment: EnvironmentModel;

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

beforeEach(async () => {
jest.clearAllMocks();
await TestBed.configureTestingModule({
imports: [OSFTestingModule],
providers: [OSFConfigService],
}).compileComponents();
const setupBrowser = () => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting(), MockProvider(PLATFORM_ID, 'browser')],
});

service = TestBed.inject(OSFConfigService);
httpMock = TestBed.inject(HttpTestingController);
environment = TestBed.inject(ENVIRONMENT);
});
};

it('should return a value with get()', async () => {
let loadPromise = service.load();
const request = httpMock.expectOne('/assets/config/config.json');
request.flush(mockConfig);
await loadPromise;
expect(environment.apiDomainUrl).toBe('https://api.example.com');
expect(environment.production).toBeTruthy();
loadPromise = service.load();
const setupServer = (ssrConfig: ConfigModel | null = null) => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
MockProvider(PLATFORM_ID, 'server'),
...(ssrConfig ? [{ provide: SSR_CONFIG, useValue: ssrConfig }] : []),
],
});

service = TestBed.inject(OSFConfigService);
environment = TestBed.inject(ENVIRONMENT);
};

it('should load config via HTTP on browser and merge into ENVIRONMENT', async () => {
setupBrowser();
const httpMock = TestBed.inject(HttpTestingController);

const loadPromise = service.load();
httpMock.expectOne('/assets/config/config.json').flush(mockConfig);
await loadPromise;

expect(environment.apiDomainUrl).toBe('https://api.example.com');
expect(environment.production).toBeTruthy();

expect(httpMock.verify()).toBeUndefined();
expect(environment.sentryDsn).toBe('https://sentry.example.com/123');
httpMock.verify();
});

it('should return a value with ahs()', async () => {
let loadPromise = service.load();
const request = httpMock.expectOne('/assets/config/config.json');
request.flush(mockConfig);
await loadPromise;
it('should only fetch config once on repeated load calls', async () => {
setupBrowser();
const httpMock = TestBed.inject(HttpTestingController);

const firstLoad = service.load();
httpMock.expectOne('/assets/config/config.json').flush(mockConfig);
await firstLoad;

await service.load();
httpMock.expectNone('/assets/config/config.json');

expect(environment.apiDomainUrl).toBe('https://api.example.com');
expect(environment.production).toBeTruthy();
httpMock.verify();
});

loadPromise = service.load();
it('should fallback to empty config on HTTP error', async () => {
setupBrowser();
const httpMock = TestBed.inject(HttpTestingController);
const originalUrl = environment.apiDomainUrl;

const loadPromise = service.load();
httpMock.expectOne('/assets/config/config.json').error(new ProgressEvent('error'));
await loadPromise;

expect(environment.apiDomainUrl).toBe(originalUrl);
httpMock.verify();
});

it('should load config from SSR_CONFIG on server and merge into ENVIRONMENT', async () => {
setupServer(mockConfig);

await service.load();

expect(environment.apiDomainUrl).toBe('https://api.example.com');
expect(environment.production).toBeTruthy();
expect(environment.sentryDsn).toBe('https://sentry.example.com/123');
});

it('should fallback to empty config on server when SSR_CONFIG is not provided', async () => {
setupServer();
const originalUrl = environment.apiDomainUrl;

await service.load();

expect(httpMock.verify()).toBeUndefined();
expect(environment.apiDomainUrl).toBe(originalUrl);
});
});
Loading