Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
197 changes: 197 additions & 0 deletions src/services/api/__test__/base.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
jest.mock('../../../store', () => ({
backend: undefined,
clusterName: undefined,
}));

import {isRedirectToAuth} from '../../../utils/response';
import {handleBaseApiResponseError, recoverXhrResponseFromNetworkError} from '../base';
import * as needResetModule from '../utils/needReset';

interface XhrRequestMockParams {
readyState?: number;
status?: number;
statusText?: string;
responseText?: string;
responseURL?: string;
rawHeaders?: string;
}

interface NetworkErrorMock {
name: string;
message: string;
code: string;
config: {
url: string;
method: string;
};
request?: unknown;
response?: unknown;
status?: number;
}

function createXhrRequestMock({
readyState = 4,
status = 504,
statusText = 'Deadline Exceeded',
responseText = '',
responseURL = 'https://oidc-proxy-preprod.example.net/viewer/json/whoami?database=%2Fdb',
rawHeaders = '',
}: XhrRequestMockParams = {}) {
return {
readyState,
status,
statusText,
responseText,
responseURL,
getAllResponseHeaders: () => rawHeaders,
};
}

function createNetworkError(request?: unknown): NetworkErrorMock {
return {
name: 'AxiosError',
message: 'Network Error',
code: 'ERR_NETWORK',
config: {
url: '/viewer/json/whoami?database=%2Fdb',
method: 'get',
},
request,
};
}

describe('recoverXhrResponseFromNetworkError', () => {
test('restores HTTP response details from recoverable XHR network error', () => {
const error = createNetworkError(
createXhrRequestMock({
rawHeaders: [
'Content-Type: text/plain; charset=utf-8',
'X-Worker-Name: oidc-proxy-1-vm-preprod.example.net',
'X-Trace-Id: trace-id-504',
].join('\r\n'),
}),
);

const response = recoverXhrResponseFromNetworkError(error);

expect(response).toEqual(
expect.objectContaining({
status: 504,
statusText: 'Deadline Exceeded',
data: '',
headers: {
'content-type': 'text/plain; charset=utf-8',
'x-worker-name': 'oidc-proxy-1-vm-preprod.example.net',
'x-trace-id': 'trace-id-504',
},
config: expect.objectContaining({
url: 'https://oidc-proxy-preprod.example.net/viewer/json/whoami?database=%2Fdb',
method: 'get',
}),
code: 'ERR_NETWORK',
message: 'Network Error',
}),
);
expect(error.response).toBe(response);
expect(error.status).toBe(504);
});

test('does not treat array-like errors as recoverable records', () => {
const error = Object.assign([], createNetworkError(createXhrRequestMock()));

const response = recoverXhrResponseFromNetworkError(error);

expect(response).toBeUndefined();
expect(error.response).toBeUndefined();
});

test('restores recovered auth response in shape compatible with redirect-to-auth checks', () => {
const error = createNetworkError(
createXhrRequestMock({
status: 401,
statusText: 'Unauthorized',
responseText: JSON.stringify({authUrl: 'https://auth.example.com/login'}),
rawHeaders: 'Content-Type: application/json',
}),
);

const response = recoverXhrResponseFromNetworkError(error);

expect(response).toEqual(
expect.objectContaining({
status: 401,
data: {authUrl: 'https://auth.example.com/login'},
}),
);
expect(isRedirectToAuth(response)).toBe(true);
});

test.each([
{
title: 'status is zero',
request: createXhrRequestMock({status: 0}),
},
{
title: 'readyState is not done',
request: createXhrRequestMock({readyState: 3}),
},
{
title: 'request does not expose headers reader',
request: {
readyState: 4,
status: 504,
statusText: 'Deadline Exceeded',
},
},
{
title: 'request is missing entirely',
request: undefined,
},
])('does not restore response when $title', ({request}) => {
const error = createNetworkError(request);

const response = recoverXhrResponseFromNetworkError(error);

expect(response).toBeUndefined();
expect(error.response).toBeUndefined();
});
});

describe('handleBaseApiResponseError', () => {
afterEach(() => {
jest.restoreAllMocks();
});

test('preserves NEED_RESET behavior after recovered response JSON parsing', async () => {
const visibilityStateDescriptor = Object.getOwnPropertyDescriptor(
document,
'visibilityState',
);
Object.defineProperty(document, 'visibilityState', {
configurable: true,
value: 'visible',
});

const processNeedResetSpy = jest
.spyOn(needResetModule, 'processNeedReset')
.mockImplementation(jest.fn());
const error = createNetworkError(
createXhrRequestMock({
status: 401,
statusText: 'Unauthorized',
responseText: JSON.stringify({code: 'NEED_RESET'}),
rawHeaders: 'Content-Type: application/json',
}),
);

await expect(handleBaseApiResponseError(error)).rejects.toBe(error);

expect(processNeedResetSpy).toHaveBeenCalledTimes(1);

if (visibilityStateDescriptor) {
Object.defineProperty(document, 'visibilityState', visibilityStateDescriptor);
} else {
Reflect.deleteProperty(document, 'visibilityState');
}
});
});
171 changes: 155 additions & 16 deletions src/services/api/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,160 @@ export interface BaseAPIParams {
useRelativePath: undefined | boolean;
}

interface XhrLikeRequest {
readyState: number;
status: number;
statusText?: string;
responseText?: string;
responseURL?: string;
getAllResponseHeaders: () => string;
}

interface RecoveredNetworkResponse {
status: number;
statusText: string;
data?: unknown;
headers: Record<string, string>;
config: Record<string, unknown>;
request: XhrLikeRequest;
code?: string;
message?: string;
}

function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
}

function isXhrLikeRequest(request: unknown): request is XhrLikeRequest {
return Boolean(
isRecord(request) &&
typeof request.readyState === 'number' &&
typeof request.status === 'number' &&
typeof request.getAllResponseHeaders === 'function',
);
}

function parseXhrResponseHeaders(rawHeaders: string): Record<string, string> {
if (!rawHeaders.trim()) {
return {};
}

const headers: Record<string, string> = {};

for (const line of rawHeaders.split(/\r?\n/)) {
const separatorIndex = line.indexOf(':');
if (separatorIndex <= 0) {
continue;
}

const headerName = line.slice(0, separatorIndex).trim().toLowerCase();
const headerValue = line.slice(separatorIndex + 1).trim();

if (!headerName || !headerValue) {
continue;
}

headers[headerName] = headers[headerName]
? `${headers[headerName]}, ${headerValue}`
: headerValue;
}

return headers;
}

function parseRecoveredResponseData(
responseText: string | undefined,
headers: Record<string, string>,
): unknown {
if (typeof responseText !== 'string') {
return undefined;
}

const trimmedResponse = responseText.trim();
if (!trimmedResponse) {
return responseText;
}

const contentType = headers['content-type']?.toLowerCase();
const shouldParseJson =
Boolean(contentType?.includes('json')) ||
trimmedResponse.startsWith('{') ||
trimmedResponse.startsWith('[');

if (!shouldParseJson) {
return responseText;
}

try {
return JSON.parse(responseText) as unknown;
} catch {
return responseText;
}
}

export function recoverXhrResponseFromNetworkError(
error: unknown,
): RecoveredNetworkResponse | undefined {
if (!isRecord(error) || error.code !== 'ERR_NETWORK' || error.response) {
return undefined;
}

const request = error.request;
if (!isXhrLikeRequest(request) || request.readyState !== 4 || request.status <= 0) {
return undefined;
}

const headers = parseXhrResponseHeaders(request.getAllResponseHeaders());
const config = isRecord(error.config) ? {...error.config} : {};

if (request.responseURL) {
config.url = request.responseURL;
}

const recoveredResponse: RecoveredNetworkResponse = {
status: request.status,
statusText: typeof request.statusText === 'string' ? request.statusText : '',
data: parseRecoveredResponseData(request.responseText, headers),
headers,
config,
request,
};

if (typeof error.code === 'string') {
recoveredResponse.code = error.code;
}

if (typeof error.message === 'string') {
recoveredResponse.message = error.message;
}

Object.assign(error, {
response: recoveredResponse,
status: request.status,
});

return recoveredResponse;
}

export function handleBaseApiResponseError(error: unknown): Promise<never> {
recoverXhrResponseFromNetworkError(error);

const response =
isRecord(error) && 'response' in error && isRecord(error.response)
? error.response
: undefined;

if (isRedirectToAuth(response)) {
window.location.assign(response.data.authUrl);
}

if (isNeedResetResponse(response?.data) && document.visibilityState === 'visible') {
processNeedReset();
}

return Promise.reject(error);
}

export class BaseYdbAPI extends AxiosWrapper {
DEFAULT_RETRIES_COUNT = 0;

Expand Down Expand Up @@ -73,22 +227,7 @@ export class BaseYdbAPI extends AxiosWrapper {
});

// Interceptor to process OIDC auth and NEED_RESET
this._axios.interceptors.response.use(null, function (error) {
const response = error.response;

// OIDC proxy returns 401 response with authUrl in it
// authUrl - external auth service link, after successful auth additional cookies will be appended
// that will allow access to clusters where OIDC proxy is a balancer
if (isRedirectToAuth(response)) {
window.location.assign(response.data.authUrl);
}

if (isNeedResetResponse(response?.data) && document.visibilityState === 'visible') {
processNeedReset();
}

return Promise.reject(error);
});
this._axios.interceptors.response.use(null, handleBaseApiResponseError);
}

getPath(path: string) {
Expand Down
Loading
Loading