blob: 95922beac0904f2a936ac1cd2dcdc76a125734bc [file]
/*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { HttpClient, HttpErrorResponse, HttpEvent, HttpHandler, HttpRequest } from '@angular/common/http';
import { Router } from '@angular/router';
import { defer, firstValueFrom, of, Subject, throwError } from 'rxjs';
import { NzMessageService } from 'ng-zorro-antd/message';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { BaseUrlService, TicketService } from '@zeppelin/services';
import { AppHttpInterceptor } from './app-http.interceptor';
const REST_BASE = 'http://localhost:8080/api';
/**
* The server answers an expired session with 405 on the REST base rather than 401, so the 405
* branch is the only path that reaches logout. The response carries no Location header, which
* is what keeps the 401 branch out of the way.
*/
function sessionExpired(url: string | undefined): HttpErrorResponse {
return new HttpErrorResponse({ status: 405, url });
}
describe('AppHttpInterceptor', () => {
let logout: ReturnType<typeof vi.fn>;
let logoutSubscribed: ReturnType<typeof vi.fn<() => void>>;
let interceptor: AppHttpInterceptor;
/** Drives one request through the interceptor and returns whatever the caller would observe. */
function intercept(failure: HttpErrorResponse, url = REST_BASE, method = 'GET'): Promise<unknown> {
const next: HttpHandler = { handle: () => throwError(() => failure) };
return firstValueFrom(interceptor.intercept(new HttpRequest(method, url, null), next));
}
beforeEach(() => {
logoutSubscribed = vi.fn<() => void>();
logout = vi.fn(() =>
defer(() => {
logoutSubscribed();
return of({});
})
);
interceptor = new AppHttpInterceptor({ logout } as unknown as TicketService);
});
it('logs out once when a non-logout request is answered with 405', async () => {
const failure = sessionExpired(`${REST_BASE}/notebook`);
await expect(intercept(failure, `${REST_BASE}/notebook`)).rejects.toBe(failure);
// `String.prototype.contains` does not exist, so this branch used to throw before reaching logout
expect(logout).toHaveBeenCalledTimes(1);
expect(logoutSubscribed).toHaveBeenCalledTimes(1);
});
it('rethrows the 405 it logged out on instead of a TypeError', async () => {
const failure = sessionExpired(`${REST_BASE}/notebook`);
await expect(intercept(failure, `${REST_BASE}/notebook`)).rejects.toBe(failure);
});
it.each([
['the logout URL', `${REST_BASE}/login/logout`],
['the redirected login URL', `${REST_BASE}/login`],
['no URL', undefined]
])('does not retry logout when its 405 response reports %s', async (_description, responseUrl) => {
const failure = sessionExpired(responseUrl);
await expect(intercept(failure, `${REST_BASE}/login/logout`, 'POST')).rejects.toBe(failure);
expect(logout).not.toHaveBeenCalled();
expect(logoutSubscribed).not.toHaveBeenCalled();
});
it('logs out on a non-logout request whose 405 reports no url', async () => {
const failure = sessionExpired(undefined);
await expect(intercept(failure, `${REST_BASE}/notebook`)).rejects.toBe(failure);
expect(logout).toHaveBeenCalledTimes(1);
expect(logoutSubscribed).toHaveBeenCalledTimes(1);
});
it('finishes ticket cleanup without another request when logout redirects to a login 405', async () => {
const requests: Array<{ url: string; response: Subject<HttpEvent<unknown>> }> = [];
const backend: HttpHandler = {
handle: request => {
const response = new Subject<HttpEvent<unknown>>();
requests.push({ url: request.url, response });
return response;
}
};
const client = new HttpClient({ handle: request => interceptor.intercept(request, backend) });
const navigate = vi.fn(() => Promise.resolve(true));
const service = new TicketService(
client,
{ getRestApiBase: () => REST_BASE } as BaseUrlService,
{ navigate } as unknown as Router,
{ success: vi.fn() } as unknown as NzMessageService
);
service.ticket.init = true;
service.ticket.principal = 'user1';
interceptor = new AppHttpInterceptor(service);
const failure = sessionExpired(`${REST_BASE}/login`);
const result = firstValueFrom(client.get(`${REST_BASE}/notebook`));
requests[0].response.error(failure);
await expect(result).rejects.toBe(failure);
expect(service.logout$.value).toBe(true);
expect(requests.map(request => request.url)).toEqual([`${REST_BASE}/notebook`, `${REST_BASE}/login/logout`]);
requests[1].response.error(sessionExpired(`${REST_BASE}/login`));
expect(requests.map(request => request.url)).toEqual([`${REST_BASE}/notebook`, `${REST_BASE}/login/logout`]);
expect(service.ticket.init).toBe(false);
expect(service.ticket.principal).toBe('');
expect(service.logout$.value).toBe(false);
expect(navigate).toHaveBeenCalledExactlyOnceWith(['/login']);
});
it.each(['complete', 'error'])('deduplicates pending logout and allows another after %s', async outcome => {
const pendingLogout = new Subject<object>();
logout.mockImplementationOnce(() =>
defer(() => {
logoutSubscribed();
return pendingLogout;
})
);
const firstFailure = sessionExpired(`${REST_BASE}/notebook`);
const secondFailure = sessionExpired(`${REST_BASE}/interpreter`);
await expect(intercept(firstFailure, `${REST_BASE}/notebook`)).rejects.toBe(firstFailure);
await expect(intercept(secondFailure, `${REST_BASE}/interpreter`)).rejects.toBe(secondFailure);
expect(logout).toHaveBeenCalledTimes(1);
expect(logoutSubscribed).toHaveBeenCalledTimes(1);
if (outcome === 'error') {
pendingLogout.error(sessionExpired(`${REST_BASE}/login`));
} else {
pendingLogout.complete();
}
await expect(intercept(firstFailure, `${REST_BASE}/notebook`)).rejects.toBe(firstFailure);
expect(logout).toHaveBeenCalledTimes(2);
expect(logoutSubscribed).toHaveBeenCalledTimes(2);
});
});