mattermost-mobile-upstream/app/client/rest/tracking.test.ts
Felipe Martin c189ca9fdd
MM-70606: Fix Edit Server pre-auth secret save on HTTPS-redirecting servers (#10252)
* fix(MM-70606): send the pre-auth secret over HTTPS only and fix Edit Server save

- sanitizeUrl: useHttp no longer downgrades an explicit https:// URL, so
  getServerUrlAfterRedirect keeps an HTTPS redirect target and strips its
  query string.
- Send the pre-auth secret header only to https/wss URLs: REST client
  default headers, per-request headers, WebSocket, and the HEAD probe.
  The secret is still persisted for the server.
- Edit Server: try HTTPS first, then fall back to HTTP (as Add Server does).
- Edit Server: recreate and reconnect the WebSocket client after the
  secret changes, so the new secret is used without an app restart.

* fix(MM-70606): keep the persisted pre-auth secret on token refresh over HTTP

ClientTracking stores the secret in its own field. The token refresh in
doFetchWithTracking reads that field instead of the request header, which
is only set over TLS, so a refresh on an http:// server no longer resets
the saved secret.

* fix(MM-70606): clarify when the pre-auth secret header is removed

* test(MM-70606): cover the pre-auth header guard in NetworkManager.createClient
2026-10-30 12:54:39 +01:00

1111 lines
44 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable max-lines */
import {DeviceEventEmitter} from 'react-native';
import LocalConfig from '@assets/config.json';
import {Events} from '@constants';
import NetworkPerformanceManager from '@managers/network_performance_manager';
import test_helper from '@test/test_helper';
import {preauthSecretInvalidMessage} from '@utils/errors';
import * as ClientConstants from './constants';
import ClientError from './error';
import ClientTracking, {testExports} from './tracking';
import type {APIClientInterface, ClientResponseMetrics} from '@mattermost/react-native-network-client';
type ParallelGroup = typeof testExports.ParallelGroup;
jest.mock('react-native', () => ({
DeviceEventEmitter: {
emit: jest.fn(),
},
Platform: {
OS: 'ios',
select: jest.fn(({ios, default: def}: any) => {
return ios ?? def;
}),
},
}));
jest.mock('@mattermost/rnutils', () => ({
getIOSAppGroupDetails: jest.fn().mockRejectedValue(''),
isRunningInSplitView: jest.fn().mockReturnValue({isSplit: false, isTablet: false}),
}));
jest.mock('expo-crypto', () => ({
randomUUID: jest.fn(() => '12345678-1234-1234-1234-1234567890ab'),
}));
jest.mock('@i18n', () => ({
t: jest.fn((key) => key),
}));
jest.mock('@utils/file', () => ({
getFormattedFileSize: jest.fn((size) => `${size} bytes`),
}));
jest.mock('@utils/log', () => ({
logDebug: jest.fn(),
logInfo: jest.fn(),
logWarning: jest.fn(),
}));
jest.mock('@utils/server', () => ({
semverFromServerVersion: jest.fn((version) => version),
}));
jest.mock('@init/credentials', () => ({
setServerCredentials: jest.fn().mockResolvedValue(undefined),
setPreauthSecret: jest.fn().mockResolvedValue(true),
}));
jest.mock('@managers/performance_metrics_manager', () => ({
collectNetworkRequestData: jest.fn(),
}));
jest.mock('@managers/network_performance_manager', () => ({
__esModule: true,
default: {
startRequestTracking: jest.fn(() => 'mock-request-id-123'),
completeRequestTracking: jest.fn(),
cancelRequestTracking: jest.fn(),
},
}));
describe('ClientTracking', () => {
const mockedNPM = jest.mocked(NetworkPerformanceManager);
const apiClientMock = {
baseUrl: 'https://example.com',
get: jest.fn(),
post: jest.fn(),
put: jest.fn(),
patch: jest.fn(),
delete: jest.fn(),
addHeaders: jest.fn().mockResolvedValue(undefined),
invalidate: jest.fn().mockResolvedValue(undefined),
};
let client: ClientTracking;
beforeAll(() => {
LocalConfig.CollectNetworkMetrics = true;
});
beforeEach(() => {
client = new ClientTracking(apiClientMock as unknown as APIClientInterface);
});
afterEach(() => {
// Clear any pending completion timers to prevent open handles
for (const groupLabel of client.requestGroups.keys()) {
client.clearCompletionTimer(groupLabel as any);
}
jest.clearAllMocks();
});
afterAll(() => {
LocalConfig.CollectNetworkMetrics = false;
});
it('should set bearer token', async () => {
const token = 'testToken';
client.setClientCredentials(token);
await new Promise(process.nextTick);
expect(client.requestHeaders[ClientConstants.HEADER_AUTH]).toBe(`${ClientConstants.HEADER_BEARER} ${token}`);
expect(require('@init/credentials').setServerCredentials).toHaveBeenCalledWith(apiClientMock.baseUrl, token);
expect(require('@init/credentials').setPreauthSecret).not.toHaveBeenCalled();
});
it('should set CSRF token', () => {
const token = 'csrfToken';
client.setCSRFToken(token);
expect(client.csrfToken).toBe(token);
});
it('should get request headers', () => {
client.setCSRFToken('csrfToken');
client.setClientCredentials('testToken');
const headers = client.getRequestHeaders('POST');
expect(headers[ClientConstants.HEADER_AUTH]).toBe(`${ClientConstants.HEADER_BEARER} testToken`);
expect(headers[ClientConstants.HEADER_X_CSRF_TOKEN]).toBe('csrfToken');
});
it('should initialize and track group data', () => {
client.initTrackGroup('Cold Start');
expect(client.requestGroups.has('Cold Start')).toBe(true);
client.trackRequest('Cold Start', 'https://example.com/api', {size: 100, compressedSize: 50} as ClientResponseMetrics);
const group = client.requestGroups.get('Cold Start')!;
expect(group.totalSize).toBe(100);
expect(group.totalCompressedSize).toBe(50);
expect(group.urls['https://example.com/api'].count).toBe(1);
});
it('should increment and decrement request count', () => {
client.initTrackGroup('Cold Start');
client.incrementRequestCount('Cold Start');
let group = client.requestGroups.get('Cold Start')!;
expect(group.activeCount).toBe(1);
client.decrementRequestCount('Cold Start');
group = client.requestGroups.get('Cold Start')!;
expect(group.activeCount).toBe(0);
});
it('should handle request completion', () => {
client.initTrackGroup('Cold Start');
client.handleRequestCompletion('Cold Start');
expect(require('@utils/log').logDebug).toHaveBeenCalledWith('Group "Cold Start" completed.');
});
it('should clear completion timer', () => {
client.initTrackGroup('Cold Start');
const group = client.requestGroups.get('Cold Start')!;
group.completionTimer = setTimeout(() => {}, 100);
client.clearCompletionTimer('Cold Start');
expect(group.completionTimer).toBeUndefined();
});
it('should check if all requests are completed', () => {
client.initTrackGroup('Cold Start');
const result = client.allRequestsCompleted('Cold Start');
expect(result).toBe(true);
});
it('should send telemetry event', () => {
client.initTrackGroup('Cold Start');
const group = client.requestGroups.get('Cold Start')!;
group.urls = {
'https://example.com/api': {
count: 2,
metrics: {
latency: 200,
startTime: Date.now(),
endTime: Date.now() + 300,
speedInMbps: 1,
networkType: 'Wi-Fi',
tlsCipherSuite: 'none',
tlsVersion: 'none',
isCached: false,
httpVersion: 'h2',
compressedSize: 10 * 1024,
size: 6 * 1024 * 1024,
connectionTime: 0,
},
},
};
client.sendTelemetryEvent('Cold Start', group, 1000);
expect(require('@utils/log').logInfo).toHaveBeenCalled();
});
it('should build request options', () => {
const options = {
body: {key: 'value'},
method: 'POST',
noRetry: true,
timeoutInterval: 5000,
headers: {custom: 'header'},
};
const result = client.buildRequestOptions(options);
expect(result.headers?.custom).toBe('header');
expect(result.retryPolicyConfiguration?.retryLimit).toBe(0);
expect(result.timeoutInterval).toBe(5000);
});
it('should perform fetch with tracking', async () => {
apiClientMock.get.mockResolvedValue({
ok: true,
data: {success: true},
headers: {},
});
const options = {
method: 'GET',
groupLabel: 'Cold Start' as RequestGroupLabel,
};
const result = await client.doFetchWithTracking('https://example.com/api', options);
expect(result).toEqual({success: true});
});
it('should attach the pre-auth secret message to a 403 pre-auth rejection', async () => {
apiClientMock.get.mockResolvedValue({
ok: false,
code: 403,
data: {message: 'forbidden'},
headers: {[ClientConstants.HEADER_X_REJECT_REASON]: 'pre-auth'},
});
const error = await client.doFetchWithTracking('https://example.com/api', {method: 'GET'}).catch((e) => e);
expect(error.intl).toBe(preauthSecretInvalidMessage);
expect(error.status_code).toBe(403);
});
it('should handle fetch errors', async () => {
apiClientMock.get.mockRejectedValue(new Error('Request failed'));
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 log sanitized request failure details without the raw error text or query string', async () => {
const logDebugSpy = jest.spyOn(require('@utils/log'), 'logDebug');
apiClientMock.get.mockRejectedValue(Object.assign(
new Error('Request failed: https://example.com/api?token=secret123'),
{status_code: 500},
));
const options = {
method: 'GET',
groupLabel: 'Cold Start' as RequestGroupLabel,
};
await expect(client.doFetchWithTracking('https://example.com/api?token=secret123', options)).rejects.toThrow('Received invalid response from the server.');
const failureLogCalls = logDebugSpy.mock.calls.filter((call) => call[0] === 'doFetchWithTracking: request failed');
expect(failureLogCalls).toHaveLength(1);
expect(failureLogCalls[0]).toEqual([
'doFetchWithTracking: request failed',
'method',
'get',
'status_code',
500,
'url',
'/api?<filtered>',
]);
for (const call of logDebugSpy.mock.calls) {
for (const arg of call) {
expect(String(arg)).not.toContain('token=secret123');
expect(String(arg)).not.toContain('Request failed');
}
}
});
it('should handle non-ok response with error details', async () => {
apiClientMock.get.mockResolvedValue({
ok: false,
data: {
message: 'Custom error message',
id: 'error_id_123',
},
code: 400,
});
const options = {
method: 'GET',
groupLabel: 'Cold Start' as RequestGroupLabel,
};
try {
await client.doFetchWithTracking('https://example.com/api', options);
} catch (error: unknown) {
expect(error).toBeInstanceOf(ClientError);
const clientError = error as ClientError;
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);
}
});
it('should handle non-ok response without error details', async () => {
apiClientMock.get.mockResolvedValue({
ok: false,
data: {},
code: 500,
});
const options = {
method: 'GET',
groupLabel: 'Cold Start' as RequestGroupLabel,
};
try {
await client.doFetchWithTracking('https://example.com/api', options);
fail('Expected error to be thrown');
} catch (error: unknown) {
const clientError = error as ClientError;
expect((clientError as {message: string}).message).toBe('Response with status code 500');
expect((clientError as {status_code: number}).status_code).toBe(500);
}
});
it('should handle response with bearer token header', async () => {
apiClientMock.get.mockResolvedValue({
ok: true,
data: {success: true},
headers: {
Token: 'new_bearer_token',
},
});
const options = {
method: 'GET',
groupLabel: 'Cold Start' as RequestGroupLabel,
};
await client.doFetchWithTracking('https://example.com/api', options);
expect(client.requestHeaders[ClientConstants.HEADER_AUTH]).toBe(`${ClientConstants.HEADER_BEARER} new_bearer_token`);
});
it('should handle response with lowercase bearer token header', async () => {
apiClientMock.get.mockResolvedValue({
ok: true,
data: {success: true},
headers: {
token: 'new_lowercase_token',
},
});
const options = {
method: 'GET',
groupLabel: 'Cold Start' as RequestGroupLabel,
};
await client.doFetchWithTracking('https://example.com/api', options);
expect(client.requestHeaders[ClientConstants.HEADER_AUTH]).toBe(`${ClientConstants.HEADER_BEARER} new_lowercase_token`);
});
it('should call increment and decrement the same number of times as doFetchWithTracking, and handleRequestCompletion only once', async () => {
const incrementSpy = jest.spyOn(client, 'incrementRequestCount');
const decrementSpy = jest.spyOn(client, 'decrementRequestCount');
const handleRequestCompletionSpy = jest.spyOn(client, 'handleRequestCompletion');
apiClientMock.get.mockImplementation(() => {
return new Promise((resolve) => {
setTimeout(() => {
resolve({
ok: true,
data: {success: true},
headers: {},
metrics: {latency: 200, size: 500, compressedSize: 300, startTime: Date.now(), endTime: Date.now() + 100, speedInMbps: 1},
});
}, 200); // Simulate a 100ms network delay
});
});
client.initTrackGroup('Cold Start');
// Simulate multiple fetch calls
const promises = [
client.doFetchWithTracking('https://example.com/api1', {method: 'GET', groupLabel: 'Cold Start'}),
client.doFetchWithTracking('https://example.com/api2', {method: 'GET', groupLabel: 'Cold Start'}),
client.doFetchWithTracking('https://example.com/api3', {method: 'GET', groupLabel: 'Cold Start'}),
];
// Wait for all fetches to resolve
await Promise.all(promises);
const group = client.requestGroups.get('Cold Start')!;
expect(group.activeCount).toBe(0);
await test_helper.wait(300);
expect(handleRequestCompletionSpy).toHaveBeenCalledTimes(1);
expect(incrementSpy).toHaveBeenCalledTimes(3);
expect(decrementSpy).toHaveBeenCalledTimes(3);
});
it('should handle invalid HTTP method', async () => {
const options = {
method: 'INVALID' as string,
groupLabel: 'Cold Start' as RequestGroupLabel,
};
const result = await client.doFetchWithTracking('https://example.com/api', options) as unknown as {error: string};
expect(result.error).toEqual(new Error('Invalid request method'));
});
it('should handle server version changes without cache control', async () => {
const emitSpy = jest.spyOn(DeviceEventEmitter, 'emit');
apiClientMock.get.mockResolvedValue({
ok: true,
data: {success: true},
headers: {
'x-version-id': '5.30.0',
},
});
await client.doFetchWithTracking('https://example.com/api', {
method: 'GET',
groupLabel: 'Cold Start',
});
expect(client.serverVersion).toBe('5.30.0');
expect(emitSpy).toHaveBeenCalledWith(
Events.SERVER_VERSION_CHANGED,
{serverUrl: 'https://example.com', serverVersion: '5.30.0'},
);
});
it('should not update server version when cache control present', async () => {
client.serverVersion = '5.20.0';
apiClientMock.get.mockResolvedValue({
ok: true,
data: {success: true},
headers: {
'x-version-id': '5.31.0',
'Cache-Control': 'no-cache',
},
});
await client.doFetchWithTracking('https://example.com/api', {
method: 'GET',
groupLabel: 'Cold Start',
});
expect(client.serverVersion).toBe('5.20.0');
});
it('should track duplicate requests correctly and log duplicate details', async () => {
const logDebugSpy = jest.spyOn(require('@utils/log'), 'logDebug');
apiClientMock.get.mockResolvedValue({
ok: true,
data: {success: true},
headers: {},
metrics: {latency: 150, size: 200, compressedSize: 100, startTime: Date.now(), endTime: Date.now() + 100, speedInMbps: 1},
});
client.initTrackGroup('Cold Start');
// Simulate multiple fetch calls to the same URL
const promises = [
client.doFetchWithTracking('https://example.com/api', {method: 'GET', groupLabel: 'Cold Start'}),
client.doFetchWithTracking('https://example.com/api', {method: 'GET', groupLabel: 'Cold Start'}),
client.doFetchWithTracking('https://example.com/api', {method: 'GET', groupLabel: 'Cold Start'}),
];
// Wait for all fetches to resolve
await Promise.all(promises);
// Verify that the group tracked duplicates correctly
const group = client.requestGroups.get('Cold Start')!;
expect(group.urls['https://example.com/api'].count).toBe(3); // URL was requested 3 times
expect(group.totalSize).toBe(600); // Total size = 3 * 200
expect(group.totalCompressedSize).toBe(300); // Total compressed size = 3 * 100
await test_helper.wait(300);
// Verify duplicate logging
expect(logDebugSpy).toHaveBeenCalledWith(
expect.stringContaining('Duplicate URLs:\n1 - https://example.com/api'),
);
});
it('should return true when checking allRequestsCompleted for non-existent group', () => {
// Try to check completion status for a non-existent group
const result = client.allRequestsCompleted('Non Existent Group' as RequestGroupLabel);
// Should return true since there are no pending requests
expect(result).toBe(true);
});
describe('Request Categorization and Speed Calculations', () => {
it('should create multiple parallel groups for non-overlapping requests', () => {
client.initTrackGroup('Cold Start');
const group = client.requestGroups.get('Cold Start')!;
const baseTime = Date.now();
// First parallel group (2 concurrent requests)
group.urls = {
'https://example.com/api1': {
count: 1,
metrics: {
latency: 100,
startTime: baseTime,
endTime: baseTime + 200,
size: 1000,
compressedSize: 500,
} as ClientResponseMetrics,
},
'https://example.com/api2': {
count: 1,
metrics: {
latency: 150,
startTime: baseTime + 50,
endTime: baseTime + 250,
size: 1000,
compressedSize: 500,
} as ClientResponseMetrics,
},
// Second parallel group (3 concurrent requests)
'https://example.com/api3': {
count: 1,
metrics: {
latency: 120,
startTime: baseTime + 300,
endTime: baseTime + 500,
size: 1000,
compressedSize: 500,
} as ClientResponseMetrics,
},
'https://example.com/api4': {
count: 1,
metrics: {
latency: 180,
startTime: baseTime + 320,
endTime: baseTime + 520,
size: 1000,
compressedSize: 500,
} as ClientResponseMetrics,
},
'https://example.com/api5': {
count: 1,
metrics: {
latency: 160,
startTime: baseTime + 340,
endTime: baseTime + 540,
size: 1000,
compressedSize: 500,
} as ClientResponseMetrics,
},
};
const result = client.categorizeRequests('Cold Start');
// Verify parallel groups
expect(result.parallelGroups).toHaveLength(2);
expect(result.maxConcurrency).toBe(3);
// First group should have 2 requests
expect(result.parallelGroups[0].requests).toHaveLength(2);
expect(result.parallelGroups[0].latency).toBe(150); // Max latency of first group
// Second group should have 3 requests
expect(result.parallelGroups[1].requests).toHaveLength(3);
expect(result.parallelGroups[1].latency).toBe(180); // Max latency of second group
});
it('should handle overlapping parallel groups correctly', () => {
client.initTrackGroup('Cold Start');
const group = client.requestGroups.get('Cold Start')!;
const baseTime = Date.now();
group.urls = {
// First request starts early and ends late
'https://example.com/api1': {
count: 1,
metrics: {
latency: 500,
startTime: baseTime,
endTime: baseTime + 1000,
size: 1000,
compressedSize: 500,
} as ClientResponseMetrics,
},
// These requests start during the first request
'https://example.com/api2': {
count: 1,
metrics: {
latency: 200,
startTime: baseTime + 100,
endTime: baseTime + 300,
size: 1000,
compressedSize: 500,
} as ClientResponseMetrics,
},
'https://example.com/api3': {
count: 1,
metrics: {
latency: 200,
startTime: baseTime + 200,
endTime: baseTime + 400,
size: 1000,
compressedSize: 500,
} as ClientResponseMetrics,
},
};
const result = client.categorizeRequests('Cold Start');
// Should create a single parallel group since all requests overlap
expect(result.parallelGroups).toHaveLength(1);
expect(result.maxConcurrency).toBe(3);
// The group should contain all 3 requests
expect(result.parallelGroups[0].requests).toHaveLength(3);
expect(result.parallelGroups[0].latency).toBe(500); // Should take the max latency
});
it('should handle requests with latency extending beyond group end time', () => {
client.initTrackGroup('Cold Start');
const group = client.requestGroups.get('Cold Start')!;
const baseTime = Date.now();
const dataTransferTime = 200;
group.urls = {
// First request starts and ends quickly
'https://example.com/api1': {
count: 1,
metrics: {
latency: 100,
startTime: baseTime,
endTime: baseTime + dataTransferTime + 100,
size: 1000,
compressedSize: 500,
} as ClientResponseMetrics,
},
// Second request starts at same time but has much longer latency
'https://example.com/api2': {
count: 1,
metrics: {
latency: 500, // Long latency
startTime: baseTime,
endTime: baseTime + dataTransferTime + 500, // Ends much later
size: 1000,
compressedSize: 500,
} as ClientResponseMetrics,
},
};
const result = client.categorizeRequests('Cold Start');
// Should create a single parallel group
expect(result.parallelGroups).toHaveLength(1);
expect(result.maxConcurrency).toBe(2);
const group1 = result.parallelGroups[0];
// Group end time should be the earliest end time
expect(group1.endTime).toBe(baseTime + dataTransferTime + 100);
// But latency should be from the longest request
expect(group1.latency).toBe(500);
});
it('should handle requests with latency extending beyond group end time and separate parallel groups', () => {
client.initTrackGroup('Cold Start');
const group = client.requestGroups.get('Cold Start')!;
const baseTime = Date.now();
const dataTransferTime = 200;
const firstGroupMinEndTime = baseTime + dataTransferTime + 100;
group.urls = {
// First parallel group
'https://example.com/api1': {
count: 1,
metrics: {
latency: 100,
startTime: baseTime,
endTime: firstGroupMinEndTime,
size: 1000,
compressedSize: 500,
} as ClientResponseMetrics,
},
'https://example.com/api2': {
count: 1,
metrics: {
latency: 500,
startTime: baseTime,
endTime: baseTime + dataTransferTime + 500,
size: 1000,
compressedSize: 500,
} as ClientResponseMetrics,
},
// Second parallel group - starts after first group has ended
'https://example.com/api3': {
count: 1,
metrics: {
latency: 300,
startTime: firstGroupMinEndTime + 1, // Starts after first group
endTime: firstGroupMinEndTime + dataTransferTime + 300,
size: 1000,
compressedSize: 500,
} as ClientResponseMetrics,
},
'https://example.com/api4': {
count: 1,
metrics: {
latency: 600,
startTime: firstGroupMinEndTime + 1, // Starts with api3
endTime: firstGroupMinEndTime + 100 + 600,
size: 1000,
compressedSize: 500,
} as ClientResponseMetrics,
},
};
const result = client.categorizeRequests('Cold Start');
// Should create two parallel groups
expect(result.parallelGroups).toHaveLength(2);
expect(result.maxConcurrency).toBe(2);
const group1 = result.parallelGroups[0];
const group2 = result.parallelGroups[1];
// First group assertions
expect(group1.endTime).toBe(baseTime + dataTransferTime + 100);
// longest latency on the first group is 500, but the first group ends after 300ms, so the effective latency is only 300ms
expect(group1.latency).toBe(300);
expect(group1.requests).toHaveLength(2);
// Second group assertions
expect(group2.endTime).toBe(firstGroupMinEndTime + dataTransferTime + 300);
// second group min endTime after 500ms, but it's the last group to end, so the effective latency for the group
// is 600ms (longest latency)
expect(group2.latency).toBe(600);
expect(group2.requests).toHaveLength(2);
});
it('should calculate average speed and effective latency for multiple parallel groups', () => {
const baseTime = Date.now();
// Create test parallel groups with known data sizes and timings
const parallelGroups: ParallelGroup[] = [
{
startTime: baseTime,
endTime: baseTime + 1000,
latency: 200,
requests: [
{
latency: 200,
startTime: baseTime,
endTime: baseTime + 1000,
compressedSize: 1000000, // 1MB
} as ClientResponseMetrics,
{
latency: 150,
startTime: baseTime + 100,
endTime: baseTime + 900,
compressedSize: 500000, // 0.5MB
} as ClientResponseMetrics,
],
},
{
startTime: baseTime + 1100,
endTime: baseTime + 2000,
latency: 300,
requests: [
{
latency: 300,
startTime: baseTime + 1100,
endTime: baseTime + 2000,
compressedSize: 2000000, // 2MB
} as ClientResponseMetrics,
{
latency: 250,
startTime: baseTime + 1200,
endTime: baseTime + 1900,
compressedSize: 1500000, // 1.5MB
} as ClientResponseMetrics,
],
},
];
// Total compressed size: 5MB (5,000,000 bytes)
// Total elapsed time: 2 seconds
// Total effective latency: 500ms (200ms + 300ms)
const result = client.calculateAverageSpeedWithCategories(parallelGroups, 2);
// Expected average speed:
// Total bits = 5,000,000 * 8 = 40,000,000 bits
// Data transfer time = 2 seconds - (500ms / 1000) = 1.5 seconds
// Speed = 40,000,000 / 1.5 = 26,666,666.67 bps = 26.67 Mbps
expect(result.averageSpeedMbps).toBeCloseTo(26.67, 1);
expect(result.effectiveLatency).toBe(500); // Sum of max latencies from each group
});
it('should handle zero transfer time in speed calculation', () => {
client.initTrackGroup('Cold Start');
const group = client.requestGroups.get('Cold Start')!;
const now = Date.now();
// Set up metrics where transfer time would be zero
group.urls = {
'https://example.com/api': {
count: 1,
metrics: {
latency: 1000,
startTime: now,
endTime: now + 1000,
speedInMbps: 0,
networkType: 'Wi-Fi',
tlsCipherSuite: 'none',
tlsVersion: 'none',
isCached: false,
httpVersion: 'h2',
size: 1000,
compressedSize: 500,
connectionTime: 0,
} as ClientResponseMetrics,
},
};
const parallelGroups: ParallelGroup[] = [{
startTime: now,
endTime: now + 1000,
latency: 1000,
requests: [group.urls['https://example.com/api'].metrics!],
}];
const result = client.calculateAverageSpeedWithCategories(
parallelGroups,
1, // 1 second elapsed
);
expect(result.averageSpeedMbps).toBe(0); // Should be 0 since data transfer time is 0/negative
expect(result.effectiveLatency).toBe(1000);
});
it('should return empty result when groupData is not found', () => {
// Try to categorize requests for a non-existent group
const result = client.categorizeRequests('Non Existent Group' as RequestGroupLabel);
// Verify empty result structure
expect(result.parallelGroups).toEqual([]);
expect(result.maxConcurrency).toBe(0);
});
it('should return default latency when groupData is not found', () => {
// Try to get average latency for a non-existent group
const result = client.getAverageLatency('Non Existent Group' as RequestGroupLabel);
// Should return default latency of 100ms
expect(result).toBe(100);
});
});
describe('setClientCredentials', () => {
it('should set shared password header when provided', () => {
client.setClientCredentials('bearer-token', 'shared-password');
expect(client.requestHeaders[ClientConstants.HEADER_AUTH]).toBe(`${ClientConstants.HEADER_BEARER} bearer-token`);
expect(client.requestHeaders[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET]).toBe('shared-password');
});
it('should persist the secret in its own keychain entry, after the token', async () => {
client.setClientCredentials('bearer-token', 'shared-password');
// The secret write is chained onto the token write, so let the microtasks drain.
await new Promise(process.nextTick);
expect(require('@init/credentials').setServerCredentials).toHaveBeenCalledWith(apiClientMock.baseUrl, 'bearer-token');
expect(require('@init/credentials').setPreauthSecret).toHaveBeenCalledWith(apiClientMock.baseUrl, 'shared-password');
});
it('should not persist the secret if storing the token fails', async () => {
jest.mocked(require('@init/credentials').setServerCredentials).
mockRejectedValueOnce(new Error('Keystore error'));
client.setClientCredentials('bearer-token', 'shared-password');
await new Promise(process.nextTick);
expect(require('@init/credentials').setPreauthSecret).not.toHaveBeenCalled();
expect(require('@utils/log').logWarning).toHaveBeenCalledWith(
'ClientTracking.setClientCredentials: persist failed',
expect.any(String),
);
});
it('should not touch the stored secret on a token-only refresh', async () => {
client.setClientCredentials('bearer-token', undefined);
await new Promise(process.nextTick);
expect(require('@init/credentials').setPreauthSecret).not.toHaveBeenCalled();
});
it('should set the pre-auth header on the live session', async () => {
await client.setPreauthSecretHeader('shared-password');
expect(client.requestHeaders[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET]).toBe('shared-password');
expect(apiClientMock.addHeaders).toHaveBeenCalledWith({
[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET]: 'shared-password',
});
});
it('should clear the pre-auth header without invalidating the session', async () => {
await client.setPreauthSecretHeader('shared-password');
await client.setPreauthSecretHeader('');
expect(client.requestHeaders[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET]).toBeUndefined();
expect(apiClientMock.addHeaders).toHaveBeenLastCalledWith({
[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET]: '',
});
expect(apiClientMock.invalidate).not.toHaveBeenCalled();
});
it('should persist but not send the secret when the server URL is not https', async () => {
const httpClient = new ClientTracking({...apiClientMock, baseUrl: 'http://example.com'} as unknown as APIClientInterface);
httpClient.setClientCredentials('bearer-token', 'shared-password');
await new Promise(process.nextTick);
expect(httpClient.requestHeaders[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET]).toBeUndefined();
expect(require('@init/credentials').setPreauthSecret).toHaveBeenCalledWith('http://example.com', 'shared-password');
});
it('should clear the pre-auth header on the live session when the server URL is not https', async () => {
const httpClient = new ClientTracking({...apiClientMock, baseUrl: 'http://example.com'} as unknown as APIClientInterface);
await httpClient.setPreauthSecretHeader('shared-password');
expect(httpClient.requestHeaders[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET]).toBeUndefined();
expect(apiClientMock.addHeaders).toHaveBeenLastCalledWith({
[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET]: '',
});
});
it('should not set the Authorization header when the bearer token is empty', () => {
client.setClientCredentials('', 'shared-password');
expect(client.requestHeaders[ClientConstants.HEADER_AUTH]).toBeUndefined();
expect(client.requestHeaders[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET]).toBe('shared-password');
});
it('should not persist anything before there is a session token', async () => {
client.setClientCredentials('', 'shared-password');
await new Promise(process.nextTick);
expect(client.requestHeaders[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET]).toBe('shared-password');
expect(require('@init/credentials').setServerCredentials).not.toHaveBeenCalled();
expect(require('@init/credentials').setPreauthSecret).not.toHaveBeenCalled();
});
it('should keep the pre-auth header when no secret is passed', () => {
client.setClientCredentials('bearer-token', 'shared-password');
// A token-only refresh must not drop a working secret (MM-70605).
client.setClientCredentials('new-bearer-token', undefined);
expect(client.requestHeaders[ClientConstants.HEADER_AUTH]).toBe(`${ClientConstants.HEADER_BEARER} new-bearer-token`);
expect(client.requestHeaders[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET]).toBe('shared-password');
});
it('should keep the pre-auth header when passed an empty string', () => {
client.setClientCredentials('bearer-token', 'shared-password');
client.setClientCredentials('bearer-token', '');
expect(client.requestHeaders[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET]).toBe('shared-password');
});
it('should not set a pre-auth header when there was never a secret', () => {
client.setClientCredentials('bearer-token', undefined);
expect(client.requestHeaders[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET]).toBeUndefined();
});
it('should always set bearer token correctly', () => {
client.setClientCredentials('test-bearer', 'shared-password');
expect(client.requestHeaders[ClientConstants.HEADER_AUTH]).toBe(`${ClientConstants.HEADER_BEARER} test-bearer`);
client.setClientCredentials('new-bearer', undefined);
expect(client.requestHeaders[ClientConstants.HEADER_AUTH]).toBe(`${ClientConstants.HEADER_BEARER} new-bearer`);
});
it('should handle multiple header updates correctly', () => {
// Set initial credentials
client.setClientCredentials('bearer1', 'password1');
expect(client.requestHeaders[ClientConstants.HEADER_AUTH]).toBe(`${ClientConstants.HEADER_BEARER} bearer1`);
expect(client.requestHeaders[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET]).toBe('password1');
// Update to new password
client.setClientCredentials('bearer2', 'password2');
expect(client.requestHeaders[ClientConstants.HEADER_AUTH]).toBe(`${ClientConstants.HEADER_BEARER} bearer2`);
expect(client.requestHeaders[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET]).toBe('password2');
// Token-only refresh keeps the last known password
client.setClientCredentials('bearer3', undefined);
expect(client.requestHeaders[ClientConstants.HEADER_AUTH]).toBe(`${ClientConstants.HEADER_BEARER} bearer3`);
expect(client.requestHeaders[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET]).toBe('password2');
});
});
describe('Network Performance Tracking', () => {
const createMockMetrics = (overrides = {}) => ({
latency: 500,
size: 1000,
compressedSize: 500,
startTime: Date.now(),
endTime: Date.now() + 500,
speedInMbps: 1,
networkType: 'Wi-Fi',
tlsCipherSuite: 'none',
tlsVersion: 'none',
isCached: false,
httpVersion: 'h2',
connectionTime: 0,
...overrides,
});
const mockSuccessResponse = (metrics = createMockMetrics()) => ({
ok: true,
data: {success: true},
headers: {},
metrics,
});
const requestOptions = {
method: 'GET',
groupLabel: 'Cold Start' as RequestGroupLabel,
};
beforeEach(() => {
jest.clearAllMocks();
});
it('should call full tracking lifecycle', async () => {
const mockMetrics = createMockMetrics();
apiClientMock.get.mockResolvedValue(mockSuccessResponse(mockMetrics));
await client.doFetchWithTracking('https://example.com/api', requestOptions);
expect(mockedNPM.startRequestTracking).toHaveBeenCalledWith('https://example.com', 'https://example.com/api');
expect(mockedNPM.completeRequestTracking).toHaveBeenCalledWith('https://example.com', 'mock-request-id-123', mockMetrics);
});
it('should pass correct server URL to NetworkPerformanceManager', async () => {
const customBaseUrl = 'https://custom-server.com';
const customApiClient = {...apiClientMock, baseUrl: customBaseUrl};
const customClient = new ClientTracking(customApiClient as unknown as APIClientInterface);
const mockMetrics = createMockMetrics({latency: 300, size: 2000, compressedSize: 1000, speedInMbps: 2});
customApiClient.get.mockResolvedValue(mockSuccessResponse(mockMetrics));
await customClient.doFetchWithTracking('https://custom-server.com/api', requestOptions);
expect(mockedNPM.startRequestTracking).toHaveBeenCalledWith(customBaseUrl, 'https://custom-server.com/api');
expect(mockedNPM.completeRequestTracking).toHaveBeenCalledWith(customBaseUrl, 'mock-request-id-123', mockMetrics);
});
it('should call cancelRequestTracking when request fails', async () => {
apiClientMock.get.mockRejectedValue(new Error('Request failed'));
await expect(client.doFetchWithTracking('https://example.com/api', requestOptions)).rejects.toThrow('Received invalid response from the server.');
expect(mockedNPM.startRequestTracking).toHaveBeenCalledWith('https://example.com', 'https://example.com/api');
expect(mockedNPM.cancelRequestTracking).toHaveBeenCalledWith('https://example.com', 'mock-request-id-123');
expect(mockedNPM.completeRequestTracking).not.toHaveBeenCalled();
});
});
});
/* eslint-enable max-lines */