Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
118 changes: 116 additions & 2 deletions app/actions/remote/general.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,12 @@ describe('doPing', () => {
expect(result).toHaveProperty('error');
});

it('should return empty object on successful ping without push proxy verification', async () => {
it('should return serverUrl on successful ping without push proxy verification', async () => {
const mockClient = makePingClient();
(NetworkManager.createClient as jest.Mock).mockResolvedValue(mockClient);

const result = await doPing(serverUrl, false);
expect(result).toEqual({});
expect(result).toEqual({serverUrl});
});

it('should return certificate error on 401 response', async () => {
Expand All @@ -69,6 +69,120 @@ describe('doPing', () => {
expect(NetworkManager.invalidateClient).toHaveBeenCalledWith(serverUrl);
});

it('should retry ping once with base_url from 406 response', async () => {
const wrongUrl = 'https://example.com/wrong';
const correctUrl = 'https://example.com/correct';
const failingClient = makePingClient({
ping: jest.fn().mockResolvedValue({
ok: false,
code: 406,
data: {base_url: correctUrl},
headers: {},
}),
});
const successClient = makePingClient();
(NetworkManager.createClient as jest.Mock).
mockResolvedValueOnce(failingClient).
mockResolvedValueOnce(successClient);

const result = await doPing(wrongUrl, false);
expect(result).toEqual({serverUrl: correctUrl});
expect(NetworkManager.createClient).toHaveBeenCalledTimes(2);
expect(NetworkManager.createClient).toHaveBeenNthCalledWith(1, wrongUrl, undefined, undefined);
expect(NetworkManager.createClient).toHaveBeenNthCalledWith(2, correctUrl, undefined, undefined);
expect(NetworkManager.invalidateClient).toHaveBeenCalledWith(wrongUrl);
});

it('should sanitize base_url before retrying ping', async () => {
const wrongUrl = 'https://example.com/wrong';
const correctUrl = 'https://example.com/correct/';
const failingClient = makePingClient({
ping: jest.fn().mockResolvedValue({
ok: false,
code: 406,
data: {base_url: correctUrl},
headers: {},
}),
});
const successClient = makePingClient();
(NetworkManager.createClient as jest.Mock).
mockResolvedValueOnce(failingClient).
mockResolvedValueOnce(successClient);

const result = await doPing(wrongUrl, false);
expect(result).toEqual({serverUrl: 'https://example.com/correct'});
expect(NetworkManager.createClient).toHaveBeenNthCalledWith(2, 'https://example.com/correct', undefined, undefined);
});

it('should create a new client when retrying ping with base_url and external client was provided', async () => {
const wrongUrl = 'https://example.com/wrong';
const correctUrl = 'https://example.com/correct';
const externalClient = makePingClient({
ping: jest.fn().mockResolvedValue({
ok: false,
code: 406,
data: {base_url: correctUrl},
headers: {},
}),
});
const successClient = makePingClient();
(NetworkManager.createClient as jest.Mock).mockResolvedValueOnce(successClient);

const result = await doPing(wrongUrl, false, 5000, undefined, externalClient as any);
expect(result).toEqual({serverUrl: correctUrl});
expect(NetworkManager.createClient).toHaveBeenCalledTimes(1);
expect(NetworkManager.createClient).toHaveBeenCalledWith(correctUrl, undefined, undefined);
expect(NetworkManager.invalidateClient).not.toHaveBeenCalled();
});

it('should retry ping once with base_url from thrown 406 error', async () => {
const wrongUrl = 'https://example.com/wrong';
const correctUrl = 'https://example.com/correct';
const failingClient = makePingClient({
ping: jest.fn().mockRejectedValue({
status_code: 406,
details: {base_url: correctUrl},
}),
});
const successClient = makePingClient();
(NetworkManager.createClient as jest.Mock).
mockResolvedValueOnce(failingClient).
mockResolvedValueOnce(successClient);

const result = await doPing(wrongUrl, false);
expect(result).toEqual({serverUrl: correctUrl});
expect(NetworkManager.createClient).toHaveBeenCalledTimes(2);
});

it('should not retry ping when 406 response has no base_url', async () => {
const mockClient = makePingClient({
ping: jest.fn().mockResolvedValue({ok: false, code: 406, data: {}, headers: {}}),
});
(NetworkManager.createClient as jest.Mock).mockResolvedValue(mockClient);

const result = await doPing(serverUrl, false);
expect(result).toHaveProperty('error');
expect(NetworkManager.createClient).toHaveBeenCalledTimes(1);
});

it('should not retry ping more than once when base_url retry also fails', async () => {
const wrongUrl = 'https://example.com/wrong';
const correctUrl = 'https://example.com/correct';
const failingClient = makePingClient({
ping: jest.fn().mockResolvedValue({
ok: false,
code: 406,
data: {base_url: correctUrl},
headers: {},
}),
});
(NetworkManager.createClient as jest.Mock).mockResolvedValue(failingClient);

const result = await doPing(wrongUrl, false);
expect(result).toHaveProperty('error');
expect(NetworkManager.createClient).toHaveBeenCalledTimes(2);
});

it('should not invalidate client when client is provided externally', async () => {
const mockClient = makePingClient({ping: jest.fn().mockResolvedValue({ok: false, code: 500, headers: {}})});

Expand Down
46 changes: 38 additions & 8 deletions app/actions/remote/general.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {getExpandedLinks, getPushVerificationStatus} from '@queries/servers/syst
import {getFullErrorMessage} from '@utils/errors';
import {getResponseHeader} from '@utils/headers';
import {logDebug} from '@utils/log';
import {sanitizeUrl} from '@utils/url';

import {forceLogoutIfNecessary} from './session';

Expand All @@ -36,8 +37,19 @@ async function getDeviceIdForPing(serverUrl: string, checkDeviceId: boolean) {
return getDeviceToken();
}

function getBaseUrlFromResponseData(data: unknown): string | undefined {
if (data && typeof data === 'object' && 'base_url' in data) {
const baseUrl = data.base_url;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you sure about adding this base_url to the server response? I wouldn't expect the server to return anything like that for a 406 error, so I'd expect something like we trim the path from serverUrl. If we want to fully support subpaths with that, we could even retry multiple times, each time with one more path segment removed

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the context. I'm still not sure if I'd expect that since there's other reasons someone might accidentally request JSON from another endpoint, but I guess that makes sense when you compare to how we return the web app for any non-API URL

if (typeof baseUrl === 'string' && baseUrl.length > 0) {
return baseUrl;
}
}

return undefined;
}

// Default timeout interval for ping is 5 seconds
export const doPing = async (serverUrl: string, verifyPushProxy: boolean, timeoutInterval = 5000, preauthSecret?: string, client?: Client) => {
export const doPing = async (serverUrl: string, verifyPushProxy: boolean, timeoutInterval = 5000, preauthSecret?: string, client?: Client, hasRetriedBaseUrl = false): Promise<{error?: unknown; canReceiveNotifications?: string; serverUrl?: string; isPreauthError?: boolean}> => {
let pingClient: Client;

if (client) {
Expand Down Expand Up @@ -77,38 +89,56 @@ export const doPing = async (serverUrl: string, verifyPushProxy: boolean, timeou
if (!client) {
NetworkManager.invalidateClient(serverUrl);
}

if (response.code === 406 && !hasRetriedBaseUrl) {
const baseUrl = getBaseUrlFromResponseData(response.data);
if (baseUrl) {
return doPing(sanitizeUrl(baseUrl, false, true), verifyPushProxy, timeoutInterval, preauthSecret, undefined, true);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any chance that this throws an error that's caught by the outer try/catch?

If it can and client is undefined, it looks like we might call NetworkManager.invalidateClient twice if that matters

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In reality, the normal path is going to be the try catch. We have this as defensive coding.

Invalidating the same client twice is not an issue.

}
}

if (response.code === 403 && getResponseHeader(response.headers, ClientConstants.HEADER_X_REJECT_REASON) === 'pre-auth') {
return {error: {intl: pingError}, isPreauthError: true};
}
return {error: {intl: pingError}};
}
} catch (error) {
// Check if this is a 403 with pre-auth header
if (!client) {
NetworkManager.invalidateClient(serverUrl);
}

const errorObj = error as ClientError;

// Check if this is a 406 with base_url in the response data
if (errorObj.status_code === 406 && !hasRetriedBaseUrl) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we handling this in both the try and catch blocks? Should we perhaps take these out of the try/catch?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is the current pattern. Not great, but I didn't want to deviate from it.

const baseUrl = getBaseUrlFromResponseData(errorObj.details);
if (baseUrl) {
return doPing(sanitizeUrl(baseUrl, false, true), verifyPushProxy, timeoutInterval, preauthSecret, undefined, true);
}
}

// Check if this is a 403 with pre-auth header
if (errorObj.status_code === 403) {
if (getResponseHeader(errorObj.headers, ClientConstants.HEADER_X_REJECT_REASON) === 'pre-auth') {
return {error: {intl: pingError}, isPreauthError: true};
}
}

if (!client) {
NetworkManager.invalidateClient(serverUrl);
}
return {error: {intl: pingError}};
}

if (verifyPushProxy) {
let canReceiveNotifications = response?.data?.CanReceiveNotifications;
let canReceiveNotifications = response?.data?.CanReceiveNotifications as string | undefined;

// Already verified or old server
if (deviceId === undefined || canReceiveNotifications === null) {
canReceiveNotifications = PUSH_PROXY_RESPONSE_VERIFIED;
}

return {canReceiveNotifications};
return {canReceiveNotifications, serverUrl};
}

return {};
return {serverUrl};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};

export const getRedirectLocation = async (serverUrl: string, link: string) => {
Expand Down
17 changes: 17 additions & 0 deletions app/client/rest/tracking.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,22 @@ describe('ClientTracking', () => {
expect(result).toEqual({success: true});
});

it('should reject ok responses with text/html content type', async () => {
apiClientMock.get.mockResolvedValue({
ok: true,
code: 200,
data: '<html><body>Not Found</body></html>',
headers: {'Content-Type': 'text/html; charset=utf-8'},
});

const options = {
method: 'GET',
groupLabel: 'Cold Start' as RequestGroupLabel,
};

await expect(client.doFetchWithTracking('https://example.com/api', options)).rejects.toThrow('Received invalid response from the server.');
});

it('should handle fetch errors', async () => {
apiClientMock.get.mockRejectedValue(new Error('Request failed'));

Expand Down Expand Up @@ -267,6 +283,7 @@ describe('ClientTracking', () => {
expect((clientError as {message: string}).message).toBe('Custom error message');
expect((clientError as {server_error_id: string}).server_error_id).toBe('error_id_123');
expect((clientError as {status_code: number}).status_code).toBe(400);
expect((clientError as {details: {message: string}}).details).toEqual({message: 'Custom error message', id: 'error_id_123'});
}
});

Expand Down
16 changes: 16 additions & 0 deletions app/client/rest/tracking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import PerformanceMetricsManager from '@managers/performance_metrics_manager';
import {NetworkRequestMetrics} from '@managers/performance_metrics_manager/constant';
import {isErrorWithStatusCode} from '@utils/errors';
import {getFormattedFileSize} from '@utils/file';
import {getResponseHeader} from '@utils/headers';
import {logDebug, logInfo} from '@utils/log';
import {semverFromServerVersion} from '@utils/server';

Expand Down Expand Up @@ -432,6 +433,20 @@ export default class ClientTracking {
}

if (response.ok) {
const contentType = getResponseHeader(headers, 'Content-Type');
if (contentType?.toLowerCase().includes('text/html')) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why test for text/html instead of asserting application/json?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just in case in any API we are sending a empty content type, or anything similar. But you are probably right, and we should go with the application/json one.

throw new ClientError(this.apiClient.baseUrl, {
message: 'Received invalid response from the server.',
intl: defineMessage({
id: 'mobile.request.invalid_response',
defaultMessage: 'Received invalid response from the server.',
}),
url,
status_code: response.code,
headers,
});
}

return returnDataOnly ? (response.data || {}) : response;
}

Expand All @@ -441,6 +456,7 @@ export default class ClientTracking {
status_code: response.code,
url,
headers,
details: response.data,
});
};
}
17 changes: 11 additions & 6 deletions app/screens/server/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,11 @@ const Server = ({
return;
}

const serverUrlToUse = result.serverUrl || headRequest.url;
if (result.serverUrl && result.serverUrl !== headRequest.url) {
setUrl(result.serverUrl);
}

if (result.error) {
if (result.isPreauthError) {
setPreauthSecretError(intl.formatMessage({
Expand All @@ -354,7 +359,7 @@ const Server = ({
// has already completed.
const pushProxyVerification = result.canReceiveNotifications as string;

const data = await fetchConfigAndLicense(headRequest.url, true);
const data = await fetchConfigAndLicense(serverUrlToUse, true);
if (data.error) {
setButtonDisabled(true);
setUrlError(getErrorMessage(data.error, intl));
Expand All @@ -372,23 +377,23 @@ const Server = ({
}

if (data.config.MobileJailbreakProtection === 'true') {
const isJailbroken = await SecurityManager.isDeviceJailbroken(headRequest.url, data.config.SiteName);
const isJailbroken = await SecurityManager.isDeviceJailbroken(serverUrlToUse, data.config.SiteName);
if (isJailbroken) {
setConnecting(false);
return;
}
}

if (data.config.MobileEnableBiometrics === 'true') {
const biometricsResult = await SecurityManager.authenticateWithBiometrics(headRequest.url, data.config.SiteName);
const biometricsResult = await SecurityManager.authenticateWithBiometrics(serverUrlToUse, data.config.SiteName);
if (!biometricsResult) {
setConnecting(false);
return;
}
}

const server = await getServerByIdentifier(data.config.DiagnosticId);
const credentials = await getServerCredentials(headRequest.url);
const credentials = await getServerCredentials(serverUrlToUse);
setConnecting(false);

if (server && server.lastActiveAt > 0 && credentials?.token) {
Expand All @@ -400,7 +405,7 @@ const Server = ({
return;
}

displayLogin(headRequest.url, data.config!, data.license!);
displayLogin(serverUrlToUse, data.config!, data.license!);

// Fire the push-proxy verification alert AFTER the RNN transition to
// LoginScreen has FULLY settled. We use setTimeout (not
Expand All @@ -417,7 +422,7 @@ const Server = ({
// unaffected — the alert still targets the same serverUrl, it just
// renders on the login screen instead of the server screen.
setTimeout(() => {
canReceiveNotifications(headRequest.url, pushProxyVerification, intl);
canReceiveNotifications(serverUrlToUse, pushProxyVerification, intl);
}, 1000);
};

Expand Down
10 changes: 6 additions & 4 deletions app/utils/url/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,17 +45,19 @@ export function isParsableUrl(url: string): boolean {
}
}

export function sanitizeUrl(url: string, useHttp = false) {
export function sanitizeUrl(url: string, useHttp = false, keepProtocol = false) {
let preUrl = urlParse(url, true);
let protocol = useHttp ? 'http:' : preUrl.protocol;

if (!preUrl.host || preUrl.protocol === 'file:') {
preUrl = urlParse('https://' + stripTrailingSlashes(url), true);
}

if (preUrl.protocol === 'http:' && !useHttp) {
let protocol;
if (keepProtocol) {
protocol = preUrl.protocol;
} else if (preUrl.protocol === 'http:' && !useHttp) {
protocol = 'https:';
} else if (!protocol) {
} else {
protocol = useHttp ? 'http:' : 'https:';
}

Expand Down
Loading
Loading