mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
a13b968451
Represent withRequestsMadeViaParent() with an internal delegating backend so the interceptor handler can distinguish delegated clients from independent child configurations.
(cherry picked from commit bb78286e5e)
1395 lines
49 KiB
TypeScript
1395 lines
49 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright Google LLC All Rights Reserved.
|
|
*
|
|
* Use of this source code is governed by an MIT-style license that can be
|
|
* found in the LICENSE file at https://angular.dev/license
|
|
*/
|
|
|
|
import {DOCUMENT} from '../../index';
|
|
import {
|
|
ApplicationRef,
|
|
Component,
|
|
createEnvironmentInjector,
|
|
EnvironmentInjector,
|
|
Injectable,
|
|
PLATFORM_ID,
|
|
TransferState,
|
|
makeStateKey,
|
|
} from '@angular/core';
|
|
import {TestBed} from '@angular/core/testing';
|
|
import {useAutoTick, timeout, withBody} from '@angular/private/testing';
|
|
import {BehaviorSubject, Observable, of} from 'rxjs';
|
|
|
|
import {
|
|
HttpClient,
|
|
HttpHeaders,
|
|
HttpParams,
|
|
HttpRequest,
|
|
HttpResponse,
|
|
provideHttpClient,
|
|
withInterceptors,
|
|
withRequestsMadeViaParent,
|
|
} from '../public_api';
|
|
import {
|
|
BODY,
|
|
CACHE_OPTIONS,
|
|
HEADERS,
|
|
HTTP_TRANSFER_CACHE_ORIGIN_MAP,
|
|
RESPONSE_TYPE,
|
|
STATUS,
|
|
STATUS_TEXT,
|
|
REQ_URL,
|
|
transferCacheInterceptorFn,
|
|
withHttpTransferCache,
|
|
generateHash,
|
|
} from '../src/transfer_cache';
|
|
import {HttpTestingController, provideHttpClientTesting} from '../testing';
|
|
import {PLATFORM_BROWSER_ID, PLATFORM_SERVER_ID} from '../../src/platform_id';
|
|
|
|
interface RequestParams {
|
|
method?: string;
|
|
observe?: 'body' | 'response';
|
|
transferCache?: {includeHeaders: string[]} | boolean;
|
|
headers?: {[key: string]: string};
|
|
/** Separate response headers for flush(); falls back to headers if not set */
|
|
responseHeaders?: {[key: string]: string};
|
|
withCredentials?: boolean;
|
|
credentials?: RequestCredentials;
|
|
cache?: RequestCache;
|
|
body?: RequestBody;
|
|
}
|
|
|
|
type RequestBody =
|
|
| ArrayBuffer
|
|
| Blob
|
|
| boolean
|
|
| string
|
|
| number
|
|
| Object
|
|
| (boolean | string | number | Object | null)[]
|
|
| null;
|
|
|
|
describe('TransferCache', () => {
|
|
useAutoTick();
|
|
@Component({
|
|
selector: 'test-app-http',
|
|
template: 'hello',
|
|
standalone: false,
|
|
})
|
|
class SomeComponent {}
|
|
|
|
describe('transferCacheInterceptorFn', () => {
|
|
afterEach(() => {
|
|
TestBed.resetTestingModule();
|
|
});
|
|
|
|
function configureInterceptor(options: {includeRequestsWithAuthHeaders?: boolean} = {}): void {
|
|
TestBed.resetTestingModule();
|
|
TestBed.configureTestingModule({
|
|
providers: [
|
|
TransferState,
|
|
{
|
|
provide: CACHE_OPTIONS,
|
|
useValue: {
|
|
isCacheActive: true,
|
|
...options,
|
|
},
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
function runOnServer<T>(callback: () => T): T {
|
|
const previousServerMode = globalThis['ngServerMode'];
|
|
globalThis['ngServerMode'] = true;
|
|
try {
|
|
return callback();
|
|
} finally {
|
|
globalThis['ngServerMode'] = previousServerMode;
|
|
}
|
|
}
|
|
|
|
function runInterceptor(
|
|
req: HttpRequest<unknown>,
|
|
next: (req: HttpRequest<unknown>) => Observable<HttpResponse<unknown>>,
|
|
): HttpResponse<unknown> {
|
|
let response!: HttpResponse<unknown>;
|
|
TestBed.runInInjectionContext(() => {
|
|
transferCacheInterceptorFn(req, next).subscribe((event) => {
|
|
if (event instanceof HttpResponse) {
|
|
response = event;
|
|
}
|
|
});
|
|
});
|
|
return response;
|
|
}
|
|
|
|
it('should not reuse cached responses for Cookie-bearing requests by default', () => {
|
|
configureInterceptor();
|
|
|
|
const firstRequest = new HttpRequest('GET', '/test-cookie', null, {
|
|
headers: new HttpHeaders({Cookie: 'session=user-a'}),
|
|
});
|
|
const secondRequest = new HttpRequest('GET', '/test-cookie', null, {
|
|
headers: new HttpHeaders({Cookie: 'session=user-b'}),
|
|
});
|
|
|
|
const firstNext = jasmine
|
|
.createSpy('firstNext')
|
|
.and.returnValue(of(new HttpResponse({body: 'user-a-secret'})));
|
|
const secondNext = jasmine
|
|
.createSpy('secondNext')
|
|
.and.returnValue(of(new HttpResponse({body: 'user-b-secret'})));
|
|
|
|
runOnServer(() => {
|
|
expect(runInterceptor(firstRequest, firstNext).body).toBe('user-a-secret');
|
|
expect(runInterceptor(secondRequest, secondNext).body).toBe('user-b-secret');
|
|
});
|
|
|
|
expect(firstNext).toHaveBeenCalledTimes(1);
|
|
expect(secondNext).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("should preserve opt-in caching for Cookie-bearing requests when 'includeRequestsWithAuthHeaders' is true", () => {
|
|
configureInterceptor({includeRequestsWithAuthHeaders: true});
|
|
|
|
const request = new HttpRequest('GET', '/test-cookie', null, {
|
|
headers: new HttpHeaders({Cookie: 'session=user-a'}),
|
|
});
|
|
|
|
const firstNext = jasmine
|
|
.createSpy('firstNext')
|
|
.and.returnValue(of(new HttpResponse({body: 'user-a-secret'})));
|
|
const secondNext = jasmine
|
|
.createSpy('secondNext')
|
|
.and.returnValue(of(new HttpResponse({body: 'network-should-not-run'})));
|
|
|
|
runOnServer(() => {
|
|
expect(runInterceptor(request, firstNext).body).toBe('user-a-secret');
|
|
expect(runInterceptor(request, secondNext).body).toBe('user-a-secret');
|
|
});
|
|
|
|
expect(firstNext).toHaveBeenCalledTimes(1);
|
|
expect(secondNext).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should not cache responses with Cache-Control: no-store', () => {
|
|
configureInterceptor();
|
|
|
|
const request = new HttpRequest('GET', '/test-no-store');
|
|
|
|
const firstNext = jasmine.createSpy('firstNext').and.returnValue(
|
|
of(
|
|
new HttpResponse({
|
|
body: 'sensitive-data',
|
|
headers: new HttpHeaders({'Cache-Control': 'no-store'}),
|
|
}),
|
|
),
|
|
);
|
|
const secondNext = jasmine
|
|
.createSpy('secondNext')
|
|
.and.returnValue(of(new HttpResponse({body: 'fresh-data'})));
|
|
|
|
runOnServer(() => {
|
|
expect(runInterceptor(request, firstNext).body).toBe('sensitive-data');
|
|
expect(runInterceptor(request, secondNext).body).toBe('fresh-data');
|
|
});
|
|
|
|
expect(firstNext).toHaveBeenCalledTimes(1);
|
|
expect(secondNext).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should not cache responses with Cache-Control: private', () => {
|
|
configureInterceptor();
|
|
|
|
const request = new HttpRequest('GET', '/test-private');
|
|
|
|
const firstNext = jasmine.createSpy('firstNext').and.returnValue(
|
|
of(
|
|
new HttpResponse({
|
|
body: 'user-data',
|
|
headers: new HttpHeaders({'Cache-Control': 'private'}),
|
|
}),
|
|
),
|
|
);
|
|
const secondNext = jasmine
|
|
.createSpy('secondNext')
|
|
.and.returnValue(of(new HttpResponse({body: 'public-data'})));
|
|
|
|
runOnServer(() => {
|
|
expect(runInterceptor(request, firstNext).body).toBe('user-data');
|
|
expect(runInterceptor(request, secondNext).body).toBe('public-data');
|
|
});
|
|
|
|
expect(firstNext).toHaveBeenCalledTimes(1);
|
|
expect(secondNext).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should not cache responses with a Set-Cookie header', () => {
|
|
configureInterceptor();
|
|
|
|
const request = new HttpRequest('GET', '/test-set-cookie');
|
|
|
|
const firstNext = jasmine.createSpy('firstNext').and.returnValue(
|
|
of(
|
|
new HttpResponse({
|
|
body: 'user-a-session',
|
|
headers: new HttpHeaders({'Set-Cookie': 'session=user-a; HttpOnly'}),
|
|
}),
|
|
),
|
|
);
|
|
const secondNext = jasmine
|
|
.createSpy('secondNext')
|
|
.and.returnValue(of(new HttpResponse({body: 'user-b-session'})));
|
|
|
|
runOnServer(() => {
|
|
expect(runInterceptor(request, firstNext).body).toBe('user-a-session');
|
|
expect(runInterceptor(request, secondNext).body).toBe('user-b-session');
|
|
});
|
|
|
|
expect(firstNext).toHaveBeenCalledTimes(1);
|
|
expect(secondNext).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should not cache requests with Cache-Control: no-store', () => {
|
|
configureInterceptor();
|
|
|
|
const request = new HttpRequest('GET', '/test-req-no-store', null, {
|
|
headers: new HttpHeaders({'Cache-Control': 'no-store'}),
|
|
});
|
|
|
|
const firstNext = jasmine
|
|
.createSpy('firstNext')
|
|
.and.returnValue(of(new HttpResponse({body: 'data'})));
|
|
const secondNext = jasmine
|
|
.createSpy('secondNext')
|
|
.and.returnValue(of(new HttpResponse({body: 'fresh-data'})));
|
|
|
|
runOnServer(() => {
|
|
expect(runInterceptor(request, firstNext).body).toBe('data');
|
|
expect(runInterceptor(request, secondNext).body).toBe('fresh-data');
|
|
});
|
|
|
|
expect(firstNext).toHaveBeenCalledTimes(1);
|
|
expect(secondNext).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should not cache requests with Cache-Control: no-cache', () => {
|
|
configureInterceptor();
|
|
|
|
const request = new HttpRequest('GET', '/test-req-no-cache', null, {
|
|
headers: new HttpHeaders({'Cache-Control': 'no-cache'}),
|
|
});
|
|
|
|
const firstNext = jasmine
|
|
.createSpy('firstNext')
|
|
.and.returnValue(of(new HttpResponse({body: 'data'})));
|
|
const secondNext = jasmine
|
|
.createSpy('secondNext')
|
|
.and.returnValue(of(new HttpResponse({body: 'fresh-data'})));
|
|
|
|
runOnServer(() => {
|
|
expect(runInterceptor(request, firstNext).body).toBe('data');
|
|
expect(runInterceptor(request, secondNext).body).toBe('fresh-data');
|
|
});
|
|
|
|
expect(firstNext).toHaveBeenCalledTimes(1);
|
|
expect(secondNext).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe('withRequestsMadeViaParent()', () => {
|
|
let childInjector: EnvironmentInjector;
|
|
|
|
beforeEach(() => {
|
|
globalThis['ngServerMode'] = true;
|
|
|
|
TestBed.configureTestingModule({
|
|
providers: [
|
|
TransferState,
|
|
withHttpTransferCache({
|
|
filter: (request) => !request.headers.has('X-API-Key'),
|
|
}),
|
|
provideHttpClient(
|
|
withInterceptors([
|
|
(request, next) => {
|
|
if (request.url === '/private') {
|
|
request = request.clone({
|
|
setHeaders: {Authorization: 'Bearer server-token'},
|
|
});
|
|
} else if (request.url === '/secret') {
|
|
request = request.clone({setHeaders: {'X-API-Key': 'secret-a'}});
|
|
} else if (request.url === '/public-alias') {
|
|
request = request.clone({url: '/public'});
|
|
}
|
|
|
|
return next(request);
|
|
},
|
|
]),
|
|
),
|
|
provideHttpClientTesting(),
|
|
],
|
|
});
|
|
|
|
childInjector = createEnvironmentInjector(
|
|
[
|
|
provideHttpClient(
|
|
withInterceptors([
|
|
(request, next) =>
|
|
next(request.clone({setHeaders: {'X-Trace-Id': 'feature-request'}})),
|
|
]),
|
|
withRequestsMadeViaParent(),
|
|
),
|
|
],
|
|
TestBed.inject(EnvironmentInjector),
|
|
);
|
|
});
|
|
|
|
afterEach(() => {
|
|
childInjector.destroy();
|
|
TestBed.inject(HttpTestingController).verify();
|
|
globalThis['ngServerMode'] = undefined;
|
|
});
|
|
|
|
it('should evaluate cache eligibility and keys after parent interceptors', () => {
|
|
const httpClient = childInjector.get(HttpClient);
|
|
const httpTestingController = TestBed.inject(HttpTestingController);
|
|
const transferState = TestBed.inject(TransferState);
|
|
|
|
let privateResponse: unknown;
|
|
httpClient.get('/private').subscribe((response) => (privateResponse = response));
|
|
const privateRequest = httpTestingController.expectOne('/private');
|
|
expect(privateRequest.request.headers.get('Authorization')).toBe('Bearer server-token');
|
|
expect(privateRequest.request.headers.get('X-Trace-Id')).toBe('feature-request');
|
|
privateRequest.flush({internalSecret: 'server-only'});
|
|
|
|
expect(privateResponse).toEqual({internalSecret: 'server-only'});
|
|
expect(JSON.parse(transferState.toJson())).toEqual({});
|
|
|
|
let publicResponse: unknown;
|
|
httpClient.get('/public-alias').subscribe((response) => (publicResponse = response));
|
|
httpTestingController.expectOne('/public').flush({message: 'public'});
|
|
|
|
publicResponse = undefined;
|
|
httpClient.get('/public-alias').subscribe((response) => (publicResponse = response));
|
|
httpTestingController.expectNone('/public');
|
|
expect(publicResponse).toEqual({message: 'public'});
|
|
});
|
|
|
|
it('should evaluate cache filters after parent interceptors', () => {
|
|
const httpClient = childInjector.get(HttpClient);
|
|
const httpTestingController = TestBed.inject(HttpTestingController);
|
|
const transferState = TestBed.inject(TransferState);
|
|
|
|
let response: unknown;
|
|
httpClient.get('/secret').subscribe((value) => (response = value));
|
|
const request = httpTestingController.expectOne('/secret');
|
|
expect(request.request.headers.get('X-API-KEY')).toBe('secret-a');
|
|
request.flush({internalSecret: 'secret-only'});
|
|
|
|
expect(response).toEqual({internalSecret: 'secret-only'});
|
|
expect(JSON.parse(transferState.toJson())).toEqual({});
|
|
});
|
|
|
|
it('should evaluate cache eligibility through multiple parent clients', () => {
|
|
const grandchildInjector = createEnvironmentInjector(
|
|
[provideHttpClient(withRequestsMadeViaParent())],
|
|
childInjector,
|
|
);
|
|
|
|
try {
|
|
const httpClient = grandchildInjector.get(HttpClient);
|
|
const httpTestingController = TestBed.inject(HttpTestingController);
|
|
const transferState = TestBed.inject(TransferState);
|
|
|
|
let privateResponse: unknown;
|
|
httpClient.get('/private').subscribe((response) => (privateResponse = response));
|
|
const privateRequest = httpTestingController.expectOne('/private');
|
|
expect(privateRequest.request.headers.get('Authorization')).toBe('Bearer server-token');
|
|
privateRequest.flush({internalSecret: 'server-only'});
|
|
|
|
expect(privateResponse).toEqual({internalSecret: 'server-only'});
|
|
expect(JSON.parse(transferState.toJson())).toEqual({});
|
|
} finally {
|
|
grandchildInjector.destroy();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('withHttpTransferCache', () => {
|
|
let isStable: BehaviorSubject<boolean>;
|
|
|
|
function makeRequestAndExpectOne(
|
|
url: string,
|
|
body: RequestBody,
|
|
params?: RequestParams,
|
|
): string;
|
|
function makeRequestAndExpectOne(
|
|
url: string,
|
|
body: RequestBody,
|
|
params?: RequestParams & {observe: 'response'},
|
|
): HttpResponse<string>;
|
|
function makeRequestAndExpectOne(url: string, body: RequestBody, params?: RequestParams): any {
|
|
let response!: any;
|
|
TestBed.inject(HttpClient)
|
|
.request(params?.method ?? 'GET', url, params)
|
|
.subscribe((r) => (response = r));
|
|
TestBed.inject(HttpTestingController)
|
|
.expectOne(url)
|
|
.flush(body, {headers: params?.responseHeaders ?? params?.headers});
|
|
return response;
|
|
}
|
|
|
|
function makeRequestAndExpectNone(
|
|
url: string,
|
|
method: string = 'GET',
|
|
params?: RequestParams,
|
|
): HttpResponse<string> {
|
|
let response!: HttpResponse<string>;
|
|
TestBed.inject(HttpClient)
|
|
.request(method, url, {observe: 'response', ...params})
|
|
.subscribe((r) => (response = r));
|
|
TestBed.inject(HttpTestingController).expectNone(url);
|
|
return response;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
globalThis['ngServerMode'] = true;
|
|
});
|
|
|
|
afterEach(() => {
|
|
globalThis['ngServerMode'] = undefined;
|
|
});
|
|
|
|
beforeEach(
|
|
withBody('<test-app-http></test-app-http>', () => {
|
|
TestBed.resetTestingModule();
|
|
isStable = new BehaviorSubject<boolean>(false);
|
|
|
|
@Injectable()
|
|
class ApplicationRefPatched extends ApplicationRef {
|
|
override get isStable() {
|
|
return isStable;
|
|
}
|
|
}
|
|
|
|
TestBed.configureTestingModule({
|
|
declarations: [SomeComponent],
|
|
providers: [
|
|
{provide: PLATFORM_ID, useValue: PLATFORM_SERVER_ID},
|
|
{provide: DOCUMENT, useFactory: () => document},
|
|
{provide: ApplicationRef, useClass: ApplicationRefPatched},
|
|
withHttpTransferCache({}),
|
|
provideHttpClient(),
|
|
provideHttpClientTesting(),
|
|
],
|
|
});
|
|
|
|
const appRef = TestBed.inject(ApplicationRef);
|
|
appRef.bootstrap(SomeComponent);
|
|
}),
|
|
);
|
|
|
|
it('should store HTTP calls in cache when application is not stable', () => {
|
|
makeRequestAndExpectOne('/test', 'foo');
|
|
const transferState = TestBed.inject(TransferState);
|
|
const key = makeStateKey(Object.keys((transferState as any).store)[0]);
|
|
expect(transferState.get(key, null)).toEqual(jasmine.objectContaining({[BODY]: 'foo'}));
|
|
});
|
|
|
|
it('should cache arraybuffer responses correctly', () => {
|
|
const testData = new Uint8Array([1, 2, 3, 4, 5]).buffer;
|
|
let response!: ArrayBuffer;
|
|
TestBed.inject(HttpClient)
|
|
.get('/test-arraybuffer', {responseType: 'arraybuffer'})
|
|
.subscribe((r) => (response = r));
|
|
TestBed.inject(HttpTestingController).expectOne('/test-arraybuffer').flush(testData);
|
|
|
|
expect(new Uint8Array(response)).toEqual(new Uint8Array([1, 2, 3, 4, 5]));
|
|
|
|
let cachedResponse!: ArrayBuffer;
|
|
TestBed.inject(HttpClient)
|
|
.get('/test-arraybuffer', {responseType: 'arraybuffer'})
|
|
.subscribe((r) => (cachedResponse = r));
|
|
TestBed.inject(HttpTestingController).expectNone('/test-arraybuffer');
|
|
|
|
expect(new Uint8Array(cachedResponse)).toEqual(new Uint8Array([1, 2, 3, 4, 5]));
|
|
});
|
|
|
|
it('should cache blob responses correctly', () => {
|
|
const testData = new Uint8Array([10, 20, 30, 40, 50]).buffer;
|
|
let response!: Blob;
|
|
TestBed.inject(HttpClient)
|
|
.get('/test-blob', {responseType: 'blob'})
|
|
.subscribe((r) => (response = r));
|
|
TestBed.inject(HttpTestingController).expectOne('/test-blob').flush(testData);
|
|
|
|
expect(response instanceof Blob).toBeTrue();
|
|
expect(response.size).toBe(5);
|
|
});
|
|
|
|
it('should stop storing HTTP calls in `TransferState` after application becomes stable', async () => {
|
|
makeRequestAndExpectOne('/test-1', 'foo');
|
|
makeRequestAndExpectOne('/test-2', 'buzz');
|
|
|
|
isStable.next(true);
|
|
|
|
await timeout();
|
|
|
|
makeRequestAndExpectOne('/test-3', 'bar');
|
|
|
|
const transferState = TestBed.inject(TransferState);
|
|
expect(JSON.parse(transferState.toJson()) as Record<string, unknown>).toEqual({
|
|
'd501aa2d57b63a95df74e3b0558782b71b077974e968ed303cd30b27e4b70702': {
|
|
[BODY]: 'foo',
|
|
[HEADERS]: {},
|
|
[STATUS]: 200,
|
|
[STATUS_TEXT]: 'OK',
|
|
[REQ_URL]: '/test-1',
|
|
[RESPONSE_TYPE]: 'json',
|
|
},
|
|
'ceddc6689dc1f2fc3a0b8c364b6e00a79b99a149f27e84da87cec03d44c150c8': {
|
|
[BODY]: 'buzz',
|
|
[HEADERS]: {},
|
|
[STATUS]: 200,
|
|
[STATUS_TEXT]: 'OK',
|
|
[REQ_URL]: '/test-2',
|
|
[RESPONSE_TYPE]: 'json',
|
|
},
|
|
});
|
|
});
|
|
|
|
it(`should use calls from cache when present and application is not stable`, () => {
|
|
makeRequestAndExpectOne('/test-1', 'foo');
|
|
// Do the same call, this time it should served from cache.
|
|
makeRequestAndExpectNone('/test-1');
|
|
});
|
|
|
|
it(`should not use calls from cache when present and application is stable`, async () => {
|
|
makeRequestAndExpectOne('/test-1', 'foo');
|
|
|
|
isStable.next(true);
|
|
await timeout();
|
|
// Do the same call, this time it should go through as application is stable.
|
|
makeRequestAndExpectOne('/test-1', 'foo');
|
|
});
|
|
|
|
it(`should differentiate calls with different parameters`, async () => {
|
|
// make calls with different parameters. All of which should be saved in the state.
|
|
makeRequestAndExpectOne('/test-1?foo=1', 'foo');
|
|
makeRequestAndExpectOne('/test-1', 'foo');
|
|
makeRequestAndExpectOne('/test-1?foo=2', 'buzz');
|
|
|
|
makeRequestAndExpectNone('/test-1?foo=1');
|
|
await expectAsync(TestBed.inject(HttpClient).get('/test-1?foo=1').toPromise()).toBeResolvedTo(
|
|
'foo',
|
|
);
|
|
});
|
|
|
|
it('should differentiate repeated parameters from scalar comma parameters', () => {
|
|
let scalarResponse!: string;
|
|
TestBed.inject(HttpClient)
|
|
.get('/test-params', {params: new HttpParams().set('role', 'user,admin')})
|
|
.subscribe((response) => (scalarResponse = response as string));
|
|
TestBed.inject(HttpTestingController)
|
|
.expectOne('/test-params?role=user,admin')
|
|
.flush('scalar');
|
|
|
|
let repeatedResponse!: string;
|
|
TestBed.inject(HttpClient)
|
|
.get('/test-params', {
|
|
params: new HttpParams().append('role', 'user').append('role', 'admin'),
|
|
})
|
|
.subscribe((response) => (repeatedResponse = response as string));
|
|
TestBed.inject(HttpTestingController)
|
|
.expectOne('/test-params?role=user&role=admin')
|
|
.flush('repeated');
|
|
|
|
let repeatedCachedResponse!: string;
|
|
TestBed.inject(HttpClient)
|
|
.get('/test-params', {
|
|
params: new HttpParams().append('role', 'user').append('role', 'admin'),
|
|
})
|
|
.subscribe((response) => (repeatedCachedResponse = response as string));
|
|
TestBed.inject(HttpTestingController).expectNone('/test-params?role=user&role=admin');
|
|
|
|
expect(scalarResponse).toBe('scalar');
|
|
expect(repeatedResponse).toBe('repeated');
|
|
expect(repeatedCachedResponse).toBe('repeated');
|
|
});
|
|
|
|
it('should skip cache when specified', () => {
|
|
makeRequestAndExpectOne('/test-1?foo=1', 'foo', {transferCache: false});
|
|
// The previous request wasn't cached so this one can't use the cache
|
|
makeRequestAndExpectOne('/test-1?foo=1', 'foo');
|
|
// But this one will
|
|
makeRequestAndExpectNone('/test-1?foo=1');
|
|
});
|
|
|
|
it('should not cache a POST even with filter true specified', () => {
|
|
makeRequestAndExpectOne('/test-1?foo=1', 'post-body', {method: 'POST'});
|
|
|
|
// Previous POST request wasn't cached
|
|
makeRequestAndExpectOne('/test-1?foo=1', 'body2', {method: 'POST'});
|
|
|
|
// filter => true won't cache neither
|
|
makeRequestAndExpectOne('/test-1?foo=1', 'post-body', {method: 'POST', transferCache: true});
|
|
|
|
const response = makeRequestAndExpectOne('/test-1?foo=1', 'body2', {method: 'POST'});
|
|
expect(response).toBe('body2');
|
|
});
|
|
|
|
it('should not cache headers', async () => {
|
|
// HttpTransferCacheOptions: true = fallback to default = headers won't be cached
|
|
makeRequestAndExpectOne('/test-1?foo=1', 'foo', {
|
|
headers: {foo: 'foo', bar: 'bar'},
|
|
transferCache: true,
|
|
});
|
|
|
|
// request returns the cache without any header.
|
|
const response2 = makeRequestAndExpectNone('/test-1?foo=1');
|
|
expect(response2.headers.keys().length).toBe(0);
|
|
});
|
|
|
|
it('should cache with headers', async () => {
|
|
// headers are case not sensitive
|
|
makeRequestAndExpectOne('/test-1?foo=1', 'foo', {
|
|
headers: {foo: 'foo', bar: 'bar', 'BAZ': 'baz'},
|
|
transferCache: {includeHeaders: ['foo', 'baz']},
|
|
});
|
|
|
|
const consoleWarnSpy = spyOn(console, 'warn');
|
|
// request returns the cache with only 2 header entries.
|
|
const response = makeRequestAndExpectNone('/test-1?foo=1', 'GET', {
|
|
transferCache: {includeHeaders: ['foo', 'baz']},
|
|
});
|
|
expect(response.headers.keys().length).toBe(2);
|
|
|
|
// foo has been kept
|
|
const foo = response.headers.get('foo');
|
|
expect(foo).toBe('foo');
|
|
|
|
// foo wasn't removed, we won't log anything
|
|
expect(consoleWarnSpy.calls.count()).toBe(0);
|
|
|
|
// bar has been removed
|
|
response.headers.get('bar');
|
|
response.headers.get('some-other-header');
|
|
|
|
expect(consoleWarnSpy.calls.count()).toBe(2);
|
|
|
|
response.headers.get('some-other-header');
|
|
|
|
// We ensure the warning is only logged once per header method + entry
|
|
expect(consoleWarnSpy.calls.count()).toBe(2);
|
|
|
|
response.headers.has('some-other-header');
|
|
|
|
// Here the method is different, we get one more call.
|
|
expect(consoleWarnSpy.calls.count()).toBe(3);
|
|
});
|
|
|
|
it('should not cache POST by default', () => {
|
|
makeRequestAndExpectOne('/test-1?foo=1', 'foo', {method: 'POST'});
|
|
makeRequestAndExpectOne('/test-1?foo=1', 'foo', {method: 'POST'});
|
|
});
|
|
|
|
it('should cache POST with the transferCache option', () => {
|
|
makeRequestAndExpectOne('/test-1?foo=1', 'foo', {method: 'POST', transferCache: true});
|
|
makeRequestAndExpectNone('/test-1?foo=1', 'POST', {transferCache: true});
|
|
|
|
makeRequestAndExpectOne('/test-2?foo=1', 'foo', {
|
|
method: 'POST',
|
|
transferCache: {includeHeaders: []},
|
|
});
|
|
makeRequestAndExpectNone('/test-2?foo=1', 'POST', {transferCache: true});
|
|
});
|
|
|
|
it('should not cache request that requires authorization by default', async () => {
|
|
makeRequestAndExpectOne('/test-auth', 'foo', {
|
|
headers: {Authorization: 'Basic YWxhZGRpbjpvcGVuc2VzYW1l'},
|
|
});
|
|
|
|
makeRequestAndExpectOne('/test-auth', 'foo');
|
|
});
|
|
|
|
it('should not cache request that requires proxy authorization by default', async () => {
|
|
makeRequestAndExpectOne('/test-auth', 'foo', {
|
|
headers: {'Proxy-Authorization': 'Basic YWxhZGRpbjpvcGVuc2VzYW1l'},
|
|
});
|
|
|
|
makeRequestAndExpectOne('/test-auth', 'foo');
|
|
});
|
|
|
|
it('should not cache requests with credentials', async () => {
|
|
makeRequestAndExpectOne('/test-auth', 'foo', {
|
|
withCredentials: true,
|
|
});
|
|
|
|
makeRequestAndExpectOne('/test-auth', 'foo', {
|
|
withCredentials: true,
|
|
});
|
|
});
|
|
|
|
it('should not cache requests with included credentials', async () => {
|
|
makeRequestAndExpectOne('/test-auth', 'foo', {
|
|
credentials: 'include',
|
|
});
|
|
|
|
makeRequestAndExpectOne('/test-auth', 'foo', {
|
|
credentials: 'include',
|
|
});
|
|
});
|
|
|
|
it('should not cache requests with same-origin credentials', async () => {
|
|
makeRequestAndExpectOne('/test-auth', 'foo', {
|
|
credentials: 'same-origin',
|
|
});
|
|
|
|
makeRequestAndExpectOne('/test-auth', 'foo', {
|
|
credentials: 'same-origin',
|
|
});
|
|
});
|
|
|
|
it('should cache requests with omitted credentials', async () => {
|
|
makeRequestAndExpectOne('/test-auth', 'foo', {
|
|
credentials: 'omit',
|
|
});
|
|
|
|
makeRequestAndExpectNone('/test-auth', 'GET', {
|
|
credentials: 'omit',
|
|
});
|
|
});
|
|
|
|
it('should not cache responses with Cache-Control: no-store', () => {
|
|
makeRequestAndExpectOne('/test-no-store', 'private-data', {
|
|
responseHeaders: {'Cache-Control': 'no-store'},
|
|
});
|
|
|
|
makeRequestAndExpectOne('/test-no-store', 'fresh-data');
|
|
});
|
|
|
|
it('should not cache responses with Cache-Control: private', () => {
|
|
makeRequestAndExpectOne('/test-private', 'user-data', {
|
|
responseHeaders: {'Cache-Control': 'private'},
|
|
});
|
|
|
|
makeRequestAndExpectOne('/test-private', 'fresh-data');
|
|
});
|
|
|
|
it('should not cache responses with a Set-Cookie header', () => {
|
|
makeRequestAndExpectOne('/test-set-cookie', 'user-a-session', {
|
|
responseHeaders: {'Set-Cookie': 'session=user-a; HttpOnly'},
|
|
});
|
|
|
|
makeRequestAndExpectOne('/test-set-cookie', 'user-b-session');
|
|
});
|
|
|
|
it('should not cache responses with Cache-Control containing no-store among other directives', () => {
|
|
makeRequestAndExpectOne('/test-multi', 'data', {
|
|
responseHeaders: {'Cache-Control': 'max-age=0, no-store, must-revalidate'},
|
|
});
|
|
|
|
makeRequestAndExpectOne('/test-multi', 'fresh-data');
|
|
});
|
|
|
|
it('should not cache responses with Cache-Control containing private among other directives', () => {
|
|
makeRequestAndExpectOne('/test-multi-private', 'data', {
|
|
responseHeaders: {'Cache-Control': 'max-age=60, private'},
|
|
});
|
|
|
|
makeRequestAndExpectOne('/test-multi-private', 'fresh-data');
|
|
});
|
|
|
|
it('should cache responses with Cache-Control: public', () => {
|
|
makeRequestAndExpectOne('/test-public', 'public-data', {
|
|
responseHeaders: {'Cache-Control': 'public'},
|
|
});
|
|
|
|
makeRequestAndExpectNone('/test-public');
|
|
});
|
|
|
|
it('should cache responses with Cache-Control: max-age without no-store or private', () => {
|
|
makeRequestAndExpectOne('/test-max-age', 'cacheable-data', {
|
|
responseHeaders: {'Cache-Control': 'max-age=3600'},
|
|
});
|
|
|
|
makeRequestAndExpectNone('/test-max-age');
|
|
});
|
|
|
|
it('should cache responses without Cache-Control header', () => {
|
|
makeRequestAndExpectOne('/test-no-cc', 'data');
|
|
|
|
makeRequestAndExpectNone('/test-no-cc');
|
|
});
|
|
|
|
it('should not cache responses with Cache-Control: no-store (case-insensitive)', () => {
|
|
makeRequestAndExpectOne('/test-case-resp', 'data', {
|
|
responseHeaders: {'Cache-Control': 'No-Store'},
|
|
});
|
|
|
|
makeRequestAndExpectOne('/test-case-resp', 'fresh-data');
|
|
});
|
|
|
|
it('should not cache requests with Cache-Control: no-store', () => {
|
|
makeRequestAndExpectOne('/test-req-no-store', 'data', {
|
|
headers: {'Cache-Control': 'no-store'},
|
|
});
|
|
|
|
makeRequestAndExpectOne('/test-req-no-store', 'fresh-data');
|
|
});
|
|
|
|
it('should not cache requests with Cache-Control: no-cache', () => {
|
|
makeRequestAndExpectOne('/test-req-no-cache', 'data', {
|
|
headers: {'Cache-Control': 'no-cache'},
|
|
});
|
|
|
|
makeRequestAndExpectOne('/test-req-no-cache', 'fresh-data');
|
|
});
|
|
|
|
it('should not cache requests with Cache-Control containing no-store among other directives', () => {
|
|
makeRequestAndExpectOne('/test-req-multi', 'data', {
|
|
headers: {'Cache-Control': 'max-age=0, no-store'},
|
|
});
|
|
|
|
makeRequestAndExpectOne('/test-req-multi', 'fresh-data');
|
|
});
|
|
|
|
it('should cache requests with Cache-Control: max-age', () => {
|
|
makeRequestAndExpectOne('/test-req-max-age', 'data', {
|
|
headers: {'Cache-Control': 'max-age=3600'},
|
|
});
|
|
|
|
makeRequestAndExpectNone('/test-req-max-age');
|
|
});
|
|
|
|
it('should not cache requests with Fetch API cache mode: no-store', () => {
|
|
makeRequestAndExpectOne('/test-fetch-no-store', 'data', {
|
|
cache: 'no-store',
|
|
});
|
|
|
|
makeRequestAndExpectOne('/test-fetch-no-store', 'fresh-data');
|
|
});
|
|
|
|
it('should not cache requests with Fetch API cache mode: no-cache', () => {
|
|
makeRequestAndExpectOne('/test-fetch-no-cache', 'data', {
|
|
cache: 'no-cache',
|
|
});
|
|
|
|
makeRequestAndExpectOne('/test-fetch-no-cache', 'fresh-data');
|
|
});
|
|
|
|
it('should cache POST with the differing body in string form', () => {
|
|
makeRequestAndExpectOne('/test-1', null, {method: 'POST', transferCache: true, body: 'foo'});
|
|
makeRequestAndExpectNone('/test-1', 'POST', {transferCache: true, body: 'foo'});
|
|
makeRequestAndExpectOne('/test-1', null, {method: 'POST', transferCache: true, body: 'bar'});
|
|
});
|
|
|
|
it('should differentiate POST requests with an ambiguous url/body boundary', () => {
|
|
// `/items/a` with body `b|c` and `/items/a|b` with body `c` are different requests, but a
|
|
// cache key that concatenates the fields with `|` maps both to the same string. The second
|
|
// request must be treated as a cache miss and hit the network.
|
|
makeRequestAndExpectOne('/items/a', null, {method: 'POST', transferCache: true, body: 'b|c'});
|
|
makeRequestAndExpectNone('/items/a', 'POST', {transferCache: true, body: 'b|c'});
|
|
makeRequestAndExpectOne('/items/a|b', null, {method: 'POST', transferCache: true, body: 'c'});
|
|
});
|
|
|
|
it('should cache POST with the differing body in object form', () => {
|
|
makeRequestAndExpectOne('/test-1', null, {
|
|
method: 'POST',
|
|
transferCache: true,
|
|
body: {foo: true},
|
|
});
|
|
makeRequestAndExpectNone('/test-1', 'POST', {transferCache: true, body: {foo: true}});
|
|
makeRequestAndExpectOne('/test-1', null, {
|
|
method: 'POST',
|
|
transferCache: true,
|
|
body: {foo: false},
|
|
});
|
|
});
|
|
|
|
it('should cache POST with the differing body in URLSearchParams form', () => {
|
|
makeRequestAndExpectOne('/test-1', null, {
|
|
method: 'POST',
|
|
transferCache: true,
|
|
body: new URLSearchParams('foo=1'),
|
|
});
|
|
makeRequestAndExpectNone('/test-1', 'POST', {
|
|
transferCache: true,
|
|
body: new URLSearchParams('foo=1'),
|
|
});
|
|
makeRequestAndExpectOne('/test-1', null, {
|
|
method: 'POST',
|
|
transferCache: true,
|
|
body: new URLSearchParams('foo=2'),
|
|
});
|
|
});
|
|
|
|
describe('caching in browser context', () => {
|
|
beforeEach(() => {
|
|
globalThis['ngServerMode'] = false;
|
|
});
|
|
|
|
afterEach(() => {
|
|
globalThis['ngServerMode'] = undefined;
|
|
});
|
|
|
|
beforeEach(
|
|
withBody('<test-app-http></test-app-http>', () => {
|
|
TestBed.resetTestingModule();
|
|
isStable = new BehaviorSubject<boolean>(false);
|
|
|
|
@Injectable()
|
|
class ApplicationRefPatched extends ApplicationRef {
|
|
override get isStable() {
|
|
return new BehaviorSubject<boolean>(false);
|
|
}
|
|
}
|
|
|
|
TestBed.configureTestingModule({
|
|
declarations: [SomeComponent],
|
|
providers: [
|
|
{provide: PLATFORM_ID, useValue: PLATFORM_BROWSER_ID},
|
|
{provide: DOCUMENT, useFactory: () => document},
|
|
{provide: ApplicationRef, useClass: ApplicationRefPatched},
|
|
withHttpTransferCache({}),
|
|
provideHttpClient(),
|
|
provideHttpClientTesting(),
|
|
],
|
|
});
|
|
|
|
const appRef = TestBed.inject(ApplicationRef);
|
|
appRef.bootstrap(SomeComponent);
|
|
isStable = appRef.isStable as BehaviorSubject<boolean>;
|
|
}),
|
|
);
|
|
|
|
it('should skip storing in transfer cache when platform is browser', () => {
|
|
makeRequestAndExpectOne('/test-1?foo=1', 'foo');
|
|
makeRequestAndExpectOne('/test-1?foo=1', 'foo');
|
|
});
|
|
});
|
|
|
|
describe('caching with global setting', () => {
|
|
beforeEach(
|
|
withBody('<test-app-http></test-app-http>', () => {
|
|
TestBed.resetTestingModule();
|
|
isStable = new BehaviorSubject<boolean>(false);
|
|
|
|
@Injectable()
|
|
class ApplicationRefPatched extends ApplicationRef {
|
|
override get isStable() {
|
|
return new BehaviorSubject<boolean>(false);
|
|
}
|
|
}
|
|
|
|
TestBed.configureTestingModule({
|
|
declarations: [SomeComponent],
|
|
providers: [
|
|
{provide: PLATFORM_ID, useValue: PLATFORM_SERVER_ID},
|
|
{provide: DOCUMENT, useFactory: () => document},
|
|
{provide: ApplicationRef, useClass: ApplicationRefPatched},
|
|
withHttpTransferCache({
|
|
filter: (req) => {
|
|
if (req.url.includes('include')) {
|
|
return true;
|
|
} else if (req.url.includes('exclude')) {
|
|
return false;
|
|
} else {
|
|
return true;
|
|
}
|
|
},
|
|
includeHeaders: ['foo', 'bar'],
|
|
includePostRequests: true,
|
|
includeRequestsWithAuthHeaders: true,
|
|
}),
|
|
provideHttpClient(),
|
|
provideHttpClientTesting(),
|
|
],
|
|
});
|
|
|
|
const appRef = TestBed.inject(ApplicationRef);
|
|
appRef.bootstrap(SomeComponent);
|
|
isStable = appRef.isStable as BehaviorSubject<boolean>;
|
|
}),
|
|
);
|
|
|
|
it('should cache because of global filter', () => {
|
|
makeRequestAndExpectOne('/include?foo=1', 'foo');
|
|
makeRequestAndExpectNone('/include?foo=1');
|
|
});
|
|
|
|
it('should not cache because of global filter', () => {
|
|
makeRequestAndExpectOne('/exclude?foo=1', 'foo');
|
|
makeRequestAndExpectOne('/exclude?foo=1', 'foo');
|
|
});
|
|
|
|
it(`should cache request that requires authorization when 'includeRequestsWithAuthHeaders' is 'true'`, async () => {
|
|
makeRequestAndExpectOne('/test-auth', 'foo', {
|
|
headers: {Authorization: 'Basic YWxhZGRpbjpvcGVuc2VzYW1l'},
|
|
});
|
|
|
|
makeRequestAndExpectNone('/test-auth');
|
|
});
|
|
|
|
it(`should cache request that requires proxy authorization when 'includeRequestsWithAuthHeaders' is 'true'`, async () => {
|
|
makeRequestAndExpectOne('/test-auth', 'foo', {
|
|
headers: {'Proxy-Authorization': 'Basic YWxhZGRpbjpvcGVuc2VzYW1l'},
|
|
});
|
|
|
|
makeRequestAndExpectNone('/test-auth');
|
|
});
|
|
|
|
it(`should not cache requests with credentials when 'includeRequestsWithAuthHeaders' is 'true'`, async () => {
|
|
makeRequestAndExpectOne('/test-auth', 'foo', {
|
|
withCredentials: true,
|
|
});
|
|
|
|
makeRequestAndExpectOne('/test-auth', 'foo', {
|
|
withCredentials: true,
|
|
});
|
|
});
|
|
|
|
it(`should not cache requests with included credentials when 'includeRequestsWithAuthHeaders' is 'true'`, async () => {
|
|
makeRequestAndExpectOne('/test-auth', 'foo', {
|
|
credentials: 'include',
|
|
});
|
|
|
|
makeRequestAndExpectOne('/test-auth', 'foo', {
|
|
credentials: 'include',
|
|
});
|
|
});
|
|
|
|
it('should cache a POST request', () => {
|
|
makeRequestAndExpectOne('/include?foo=1', 'post-body', {method: 'POST'});
|
|
|
|
// Previous POST request wasn't cached
|
|
const response = makeRequestAndExpectNone('/include?foo=1', 'POST');
|
|
expect(response.body).toBe('post-body');
|
|
});
|
|
|
|
it('should cache with headers', () => {
|
|
// nothing specified, should use global options = callback => include + headers
|
|
makeRequestAndExpectOne('/include?foo=1', 'foo', {headers: {foo: 'foo', bar: 'bar'}});
|
|
|
|
// This one was cached with headers
|
|
const response = makeRequestAndExpectNone('/include?foo=1');
|
|
expect(response.headers.keys().length).toBe(2);
|
|
});
|
|
|
|
it('should cache without headers because overridden', () => {
|
|
// nothing specified, should use global options = callback => include + headers
|
|
makeRequestAndExpectOne('/include?foo=1', 'foo', {
|
|
headers: {foo: 'foo', bar: 'bar'},
|
|
transferCache: {includeHeaders: []},
|
|
});
|
|
|
|
// This one was cached with headers
|
|
const response = makeRequestAndExpectNone('/include?foo=1');
|
|
expect(response.headers.keys().length).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('caching with includeRequestsWithCredentials and includeNonCacheableRequests', () => {
|
|
beforeEach(
|
|
withBody('<test-app-http></test-app-http>', () => {
|
|
TestBed.resetTestingModule();
|
|
isStable = new BehaviorSubject<boolean>(false);
|
|
|
|
@Injectable()
|
|
class ApplicationRefPatched extends ApplicationRef {
|
|
override get isStable() {
|
|
return new BehaviorSubject<boolean>(false);
|
|
}
|
|
}
|
|
|
|
TestBed.configureTestingModule({
|
|
declarations: [SomeComponent],
|
|
providers: [
|
|
{provide: PLATFORM_ID, useValue: PLATFORM_SERVER_ID},
|
|
{provide: DOCUMENT, useFactory: () => document},
|
|
{provide: ApplicationRef, useClass: ApplicationRefPatched},
|
|
withHttpTransferCache({
|
|
includeRequestsWithCredentials: true,
|
|
includeNonCacheableRequests: true,
|
|
}),
|
|
provideHttpClient(),
|
|
provideHttpClientTesting(),
|
|
],
|
|
});
|
|
|
|
const appRef = TestBed.inject(ApplicationRef);
|
|
appRef.bootstrap(SomeComponent);
|
|
isStable = appRef.isStable as BehaviorSubject<boolean>;
|
|
}),
|
|
);
|
|
|
|
it(`should cache requests with credentials when 'includeRequestsWithCredentials' is 'true'`, async () => {
|
|
makeRequestAndExpectOne('/test-cred', 'foo', {
|
|
withCredentials: true,
|
|
});
|
|
|
|
makeRequestAndExpectNone('/test-cred');
|
|
});
|
|
|
|
it(`should cache requests with included credentials mode when 'includeRequestsWithCredentials' is 'true'`, async () => {
|
|
makeRequestAndExpectOne('/test-cred', 'foo', {
|
|
credentials: 'include',
|
|
});
|
|
|
|
makeRequestAndExpectNone('/test-cred');
|
|
});
|
|
|
|
it(`should cache requests with same-origin credentials mode when 'includeRequestsWithCredentials' is 'true'`, async () => {
|
|
makeRequestAndExpectOne('/test-cred', 'foo', {
|
|
credentials: 'same-origin',
|
|
});
|
|
|
|
makeRequestAndExpectNone('/test-cred');
|
|
});
|
|
|
|
it(`should cache responses with Cache-Control: no-store when 'includeNonCacheableRequests' is 'true'`, async () => {
|
|
makeRequestAndExpectOne('/test-cache-control', 'foo', {
|
|
responseHeaders: {'Cache-Control': 'no-store'},
|
|
});
|
|
|
|
makeRequestAndExpectNone('/test-cache-control');
|
|
});
|
|
|
|
it(`should cache responses with Cache-Control: private when 'includeNonCacheableRequests' is 'true'`, async () => {
|
|
makeRequestAndExpectOne('/test-cache-control', 'foo', {
|
|
responseHeaders: {'Cache-Control': 'private'},
|
|
});
|
|
|
|
makeRequestAndExpectNone('/test-cache-control');
|
|
});
|
|
|
|
it(`should cache requests with Cache-Control: no-cache when 'includeNonCacheableRequests' is 'true'`, async () => {
|
|
makeRequestAndExpectOne('/test-cache-control', 'foo', {
|
|
headers: {'Cache-Control': 'no-cache'},
|
|
});
|
|
|
|
makeRequestAndExpectNone('/test-cache-control');
|
|
});
|
|
|
|
it(`should cache requests with cache: 'no-store' mode when 'includeNonCacheableRequests' is 'true'`, async () => {
|
|
makeRequestAndExpectOne('/test-cache-mode', 'foo', {
|
|
cache: 'no-store',
|
|
});
|
|
|
|
makeRequestAndExpectNone('/test-cache-mode');
|
|
});
|
|
});
|
|
|
|
describe('caching with public origins', () => {
|
|
beforeEach(
|
|
withBody('<test-app-http></test-app-http>', () => {
|
|
TestBed.resetTestingModule();
|
|
isStable = new BehaviorSubject<boolean>(false);
|
|
|
|
@Injectable()
|
|
class ApplicationRefPatched extends ApplicationRef {
|
|
override get isStable() {
|
|
return new BehaviorSubject<boolean>(false);
|
|
}
|
|
}
|
|
|
|
TestBed.configureTestingModule({
|
|
declarations: [SomeComponent],
|
|
providers: [
|
|
{provide: PLATFORM_ID, useValue: PLATFORM_SERVER_ID},
|
|
{provide: DOCUMENT, useFactory: () => document},
|
|
{provide: ApplicationRef, useClass: ApplicationRefPatched},
|
|
withHttpTransferCache({}),
|
|
provideHttpClient(),
|
|
provideHttpClientTesting(),
|
|
{
|
|
provide: HTTP_TRANSFER_CACHE_ORIGIN_MAP,
|
|
useValue: {
|
|
'http://internal-domain.com:1234': 'https://external-domain.net:443',
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
const appRef = TestBed.inject(ApplicationRef);
|
|
appRef.bootstrap(SomeComponent);
|
|
isStable = appRef.isStable as BehaviorSubject<boolean>;
|
|
}),
|
|
);
|
|
|
|
it('should cache with public origin', () => {
|
|
makeRequestAndExpectOne('http://internal-domain.com:1234/test-1?foo=1', 'foo');
|
|
const cachedRequest = makeRequestAndExpectNone(
|
|
'https://external-domain.net:443/test-1?foo=1',
|
|
);
|
|
expect(cachedRequest.url).toBe('https://external-domain.net:443/test-1?foo=1');
|
|
});
|
|
|
|
it('should cache normally when there is no mapping defined for the origin', () => {
|
|
makeRequestAndExpectOne('https://other.internal-domain.com:1234/test-1?foo=1', 'foo');
|
|
makeRequestAndExpectNone('https://other.internal-domain.com:1234/test-1?foo=1');
|
|
});
|
|
|
|
describe('when the origin map is configured with extra paths', () => {
|
|
beforeEach(
|
|
withBody('<test-app-http></test-app-http>', () => {
|
|
TestBed.resetTestingModule();
|
|
isStable = new BehaviorSubject<boolean>(false);
|
|
|
|
@Injectable()
|
|
class ApplicationRefPatched extends ApplicationRef {
|
|
override get isStable() {
|
|
return new BehaviorSubject<boolean>(false);
|
|
}
|
|
}
|
|
|
|
TestBed.configureTestingModule({
|
|
declarations: [SomeComponent],
|
|
providers: [
|
|
{provide: PLATFORM_ID, useValue: PLATFORM_SERVER_ID},
|
|
{provide: DOCUMENT, useFactory: () => document},
|
|
{provide: ApplicationRef, useClass: ApplicationRefPatched},
|
|
withHttpTransferCache({}),
|
|
provideHttpClient(),
|
|
provideHttpClientTesting(),
|
|
{
|
|
provide: HTTP_TRANSFER_CACHE_ORIGIN_MAP,
|
|
useValue: {
|
|
'http://internal-domain.com:1234': 'https://external-domain.net:443/path',
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
const appRef = TestBed.inject(ApplicationRef);
|
|
appRef.bootstrap(SomeComponent);
|
|
isStable = appRef.isStable as BehaviorSubject<boolean>;
|
|
}),
|
|
);
|
|
|
|
it('should throw an error when the origin map is configured with extra paths', () => {
|
|
TestBed.inject(HttpClient)
|
|
.request('GET', 'http://internal-domain.com:1234/path/test-1')
|
|
.subscribe({
|
|
error: (error: Error) => {
|
|
expect(error.message).toBe(
|
|
'NG02804: Angular detected a URL with a path segment in the value provided for the ' +
|
|
'`HTTP_TRANSFER_CACHE_ORIGIN_MAP` token: https://external-domain.net:443/path. ' +
|
|
'The map should only contain origins without any other segments.',
|
|
);
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('on the client', () => {
|
|
beforeEach(
|
|
withBody('<test-app-http></test-app-http>', () => {
|
|
TestBed.resetTestingModule();
|
|
isStable = new BehaviorSubject<boolean>(false);
|
|
|
|
@Injectable()
|
|
class ApplicationRefPatched extends ApplicationRef {
|
|
override get isStable() {
|
|
return new BehaviorSubject<boolean>(false);
|
|
}
|
|
}
|
|
|
|
TestBed.configureTestingModule({
|
|
declarations: [SomeComponent],
|
|
providers: [
|
|
{provide: DOCUMENT, useFactory: () => document},
|
|
{provide: ApplicationRef, useClass: ApplicationRefPatched},
|
|
withHttpTransferCache({}),
|
|
provideHttpClient(),
|
|
provideHttpClientTesting(),
|
|
{
|
|
provide: HTTP_TRANSFER_CACHE_ORIGIN_MAP,
|
|
useValue: {
|
|
'http://internal-domain.com:1234': 'https://external-domain.net:443',
|
|
},
|
|
},
|
|
{provide: PLATFORM_ID, useValue: PLATFORM_SERVER_ID},
|
|
],
|
|
});
|
|
|
|
// Make a request on the server to fill the transfer state then reuse it in the browser
|
|
makeRequestAndExpectOne('http://internal-domain.com:1234/test-1?foo=1', 'foo');
|
|
const transferState = TestBed.inject(TransferState);
|
|
|
|
TestBed.resetTestingModule();
|
|
TestBed.configureTestingModule({
|
|
declarations: [SomeComponent],
|
|
providers: [
|
|
{provide: DOCUMENT, useFactory: () => document},
|
|
{provide: ApplicationRef, useClass: ApplicationRefPatched},
|
|
withHttpTransferCache({}),
|
|
provideHttpClient(),
|
|
provideHttpClientTesting(),
|
|
{
|
|
provide: HTTP_TRANSFER_CACHE_ORIGIN_MAP,
|
|
useValue: {
|
|
'http://internal-domain.com:1234': 'https://external-domain.net:443',
|
|
},
|
|
},
|
|
{provide: TransferState, useValue: transferState},
|
|
{provide: PLATFORM_ID, useValue: PLATFORM_BROWSER_ID},
|
|
],
|
|
});
|
|
|
|
const appRef = TestBed.inject(ApplicationRef);
|
|
appRef.bootstrap(SomeComponent);
|
|
isStable = appRef.isStable as BehaviorSubject<boolean>;
|
|
}),
|
|
);
|
|
|
|
it('should throw an error when origin mapping is defined', () => {
|
|
TestBed.inject(HttpClient)
|
|
.request('GET', 'https://external-domain.net:443/test-1?foo=1')
|
|
.subscribe({
|
|
error: (error: Error) => {
|
|
expect(error.message).toBe(
|
|
'NG02803: Angular detected that the `HTTP_TRANSFER_CACHE_ORIGIN_MAP` token is configured and ' +
|
|
'present in the client side code. Please ensure that this token is only provided in the ' +
|
|
'server code of the application.',
|
|
);
|
|
},
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('generateHash', () => {
|
|
async function computeNativeSha256(value: string): Promise<string> {
|
|
const msgUint8 = new TextEncoder().encode(value);
|
|
const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8);
|
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
}
|
|
|
|
it('should generate standard SHA-256 hashes matching Web Crypto specs', async () => {
|
|
const testCases = [
|
|
'',
|
|
'hello',
|
|
'angular',
|
|
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789',
|
|
'Angular 🚀',
|
|
'a'.repeat(55),
|
|
'a'.repeat(56),
|
|
'a'.repeat(63),
|
|
'a'.repeat(64),
|
|
'a'.repeat(65),
|
|
'a'.repeat(1000),
|
|
];
|
|
|
|
for (const testCase of testCases) {
|
|
const expected = await computeNativeSha256(testCase);
|
|
expect(generateHash(testCase)).withContext(`For: ${testCase}`).toBe(expected);
|
|
}
|
|
});
|
|
});
|
|
});
|