mirror of
https://github.com/mattermost/mattermost-mobile.git
synced 2026-11-09 12:40:12 +00:00
* fix(e2e): preserve iOS App Group container metadata across shard specs and use swipe helper in Edit Server spec * fix(maestro): dismiss iOS QuickPath keyboard onboarding and retry empty server URL input * fix(e2e): avoid replaying single-use FormData streams on HTML interstitials and add ABAC/login transport retries * fix(e2e): classify disabled-feature and server-timeout errors as infra in CMT notifications * fix(e2e): address CodeRabbit review on container cache, ABAC cleanup and Maestro connect scroll * fix(e2e): wait for restored denied state in upload permission spec as well * fix(e2e): confirm restored ABAC decision server-side and scroll to URL label before Maestro recovery * fix(e2e): return to channel list before ABAC cleanup logout and drop optional flag on recovery scroll * fix(e2e): retry ABAC decision login on transport failures and classify CF 521/523 as transport failures * test(e2e): cover apiUploadFileToChannel rebuilding FormData and stream on interstitial retry * fix(e2e): budget ABAC decision waits for observed server flip latency Co-authored-by: yasser khan <attitude3cena.yf@gmail.com>
291 lines
13 KiB
TypeScript
291 lines
13 KiB
TypeScript
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import {describe, it, type TestContext} from 'node:test';
|
|
|
|
import {AxiosError, type AxiosAdapter, type AxiosResponse, type InternalAxiosRequestConfig} from 'axios';
|
|
|
|
import {client, isHtmlInterstitialError, isPreConnectionFailure, PRE_CONNECTION_MAX_RETRIES} from './client';
|
|
import {apiUploadFileToChannel} from './post';
|
|
|
|
/**
|
|
* Trimmed from a real failure body in PR 10050's 40b8e5ea run: Cloudflare answered the Detox
|
|
* runners' API calls with its bot check instead of proxying to Mattermost. Kept verbatim in the
|
|
* parts that matter, because the defect this file guards was a matcher that did not recognise
|
|
* this exact markup — a paraphrased fixture would not have caught it.
|
|
*/
|
|
const CHALLENGE_HTML = [
|
|
'<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">',
|
|
'<title>Mattermost captcha challenge</title></head><body>',
|
|
'<h1>Just a moment...</h1><h2>Enable JavaScript and cookies to continue</h2>',
|
|
"<script>(function(){window._cf_chl_opt = {cFPWv: 'b'};})();</script>",
|
|
'</body></html>',
|
|
].join('');
|
|
|
|
const COLD_START_HTML = '<!DOCTYPE html><html><body>cloud/inaccessible</body></html>';
|
|
|
|
const ok = (config: InternalAxiosRequestConfig, data: unknown, status = 200): AxiosResponse => ({
|
|
config, data, status, statusText: 'OK', headers: {},
|
|
});
|
|
|
|
/**
|
|
* Installs an adapter that replays `steps` in order and holds the last one, then restores the
|
|
* previous adapter. Driving the real instance means the interceptors under test run exactly as
|
|
* they do in CI; only the transport is replaced.
|
|
*/
|
|
const withAdapter = async (
|
|
steps: Array<{status: number; data: unknown}>,
|
|
run: (calls: () => number) => Promise<void>,
|
|
capturedData: unknown[] = [],
|
|
) => {
|
|
const previous = client.defaults.adapter;
|
|
let calls = 0;
|
|
|
|
const adapter: AxiosAdapter = async (config) => {
|
|
const step = steps.at(Math.min(calls, steps.length - 1));
|
|
assert.ok(step, 'adapter step fixture must exist');
|
|
calls += 1;
|
|
capturedData.push(config.data);
|
|
const response = ok(config, step.data, step.status);
|
|
if (step.status >= 400) {
|
|
const error = new Error(`Request failed with status code ${step.status}`) as Error & {
|
|
config: InternalAxiosRequestConfig;
|
|
response: AxiosResponse;
|
|
isAxiosError: boolean;
|
|
};
|
|
error.config = config;
|
|
error.response = response;
|
|
error.isAxiosError = true;
|
|
throw error;
|
|
}
|
|
return response;
|
|
};
|
|
|
|
client.defaults.adapter = adapter;
|
|
try {
|
|
await run(() => calls);
|
|
} finally {
|
|
client.defaults.adapter = previous;
|
|
}
|
|
};
|
|
|
|
const tickRetryDelays = async (t: TestContext, delaysMs: number[]) => {
|
|
const yieldEventLoop = () => new Promise<void>((resolve) => {
|
|
setImmediate(resolve);
|
|
});
|
|
|
|
for (const delayMs of delaysMs) {
|
|
// Let the interceptor schedule setTimeout before advancing mocked time.
|
|
// eslint-disable-next-line no-await-in-loop -- retry delays are sequential
|
|
await yieldEventLoop();
|
|
t.mock.timers.tick(delayMs);
|
|
}
|
|
await yieldEventLoop();
|
|
};
|
|
|
|
describe('client HTML interstitial retry', () => {
|
|
it('should retry a Cloudflare challenge served as 403 and return the eventual API response', async (t) => {
|
|
t.mock.timers.enable({apis: ['setTimeout']});
|
|
await withAdapter(
|
|
[{status: 403, data: CHALLENGE_HTML}, {status: 200, data: {status: 'OK'}}],
|
|
async (calls) => {
|
|
const pending = client.get('http://example.test/api/v4/system/ping');
|
|
await tickRetryDelays(t, [3000]);
|
|
const response = await pending;
|
|
|
|
assert.deepEqual(response.data, {status: 'OK'});
|
|
assert.equal(calls(), 2, 'the challenge should have been retried exactly once');
|
|
},
|
|
);
|
|
});
|
|
|
|
it('should retry a Cloudflare challenge twice, advancing the 3s then 6s backoff before succeeding', async (t) => {
|
|
t.mock.timers.enable({apis: ['setTimeout']});
|
|
await withAdapter(
|
|
[
|
|
{status: 403, data: CHALLENGE_HTML},
|
|
{status: 403, data: CHALLENGE_HTML},
|
|
{status: 200, data: {status: 'OK'}},
|
|
],
|
|
async (calls) => {
|
|
const pending = client.get('http://example.test/api/v4/system/ping');
|
|
await tickRetryDelays(t, [3000, 6000]);
|
|
const response = await pending;
|
|
|
|
assert.deepEqual(response.data, {status: 'OK'});
|
|
assert.equal(calls(), 3, 'the challenge should have been retried twice');
|
|
},
|
|
);
|
|
});
|
|
|
|
it('should retry a cold-start page served as 200, which axios resolves rather than rejects', async (t) => {
|
|
t.mock.timers.enable({apis: ['setTimeout']});
|
|
await withAdapter(
|
|
[{status: 200, data: COLD_START_HTML}, {status: 200, data: {status: 'OK'}}],
|
|
async (calls) => {
|
|
const pending = client.get('http://example.test/api/v4/system/ping');
|
|
await tickRetryDelays(t, [3000]);
|
|
const response = await pending;
|
|
|
|
assert.deepEqual(response.data, {status: 'OK'});
|
|
assert.equal(calls(), 2);
|
|
},
|
|
);
|
|
});
|
|
|
|
it('should leave an ordinary 403 alone, so a real permission error still fails fast', async () => {
|
|
await withAdapter(
|
|
[{status: 403, data: {id: 'api.context.permissions.app_error', status_code: 403}}],
|
|
async (calls) => {
|
|
await assert.rejects(() => client.get('http://example.test/api/v4/system/ping'));
|
|
assert.equal(calls(), 1, 'a JSON 403 must not be retried');
|
|
},
|
|
);
|
|
});
|
|
|
|
it('should report an exhausted interstitial as such, so apiInit can spend its own budget on it', () => {
|
|
assert.equal(isHtmlInterstitialError(new Error('Server returned "_cf_chl_opt" HTML for /x (retries exhausted or retry budget spent)')), true);
|
|
assert.equal(isHtmlInterstitialError(new Error('Request failed with status code 403')), false);
|
|
assert.equal(isHtmlInterstitialError(undefined), false);
|
|
});
|
|
|
|
it('should not replay a single-use FormData stream on an HTML interstitial or CF 521', async () => {
|
|
const fakeFormData = Object.assign({pipe: () => {}}, {constructor: {name: 'FormData'}});
|
|
|
|
await withAdapter(
|
|
[{status: 200, data: COLD_START_HTML}, {status: 200, data: {file_infos: [{id: 'f1'}]}}],
|
|
async (calls) => {
|
|
await assert.rejects(
|
|
() => client.post('http://example.test/api/v4/files', fakeFormData),
|
|
(err: Error) => isHtmlInterstitialError(err),
|
|
);
|
|
assert.equal(calls(), 1, 'must fail immediately so the caller can rebuild the FormData stream');
|
|
},
|
|
);
|
|
|
|
await withAdapter(
|
|
[{status: 521, data: 'Web server is down'}, {status: 200, data: {file_infos: [{id: 'f1'}]}}],
|
|
async (calls) => {
|
|
await assert.rejects(
|
|
() => client.post('http://example.test/api/v4/files', fakeFormData),
|
|
);
|
|
assert.equal(calls(), 1, 'must not replay a consumed FormData stream on 521');
|
|
},
|
|
);
|
|
});
|
|
|
|
it('should rebuild the FormData and read stream when apiUploadFileToChannel retries after an interstitial', async (t) => {
|
|
t.mock.timers.enable({apis: ['setTimeout']});
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'client-upload-retry-'));
|
|
const filePath = path.join(dir, 'sample.txt');
|
|
fs.writeFileSync(filePath, 'sample');
|
|
const capturedData: unknown[] = [];
|
|
|
|
try {
|
|
await withAdapter(
|
|
[{status: 200, data: COLD_START_HTML}, {status: 200, data: {file_infos: [{id: 'f1'}]}}],
|
|
async (calls) => {
|
|
const pending = apiUploadFileToChannel('http://example.test', 'channel-id', filePath);
|
|
await tickRetryDelays(t, [2000]);
|
|
assert.deepEqual(await pending, {fileId: 'f1'});
|
|
assert.equal(calls(), 2, 'the upload should have been retried exactly once');
|
|
},
|
|
capturedData,
|
|
);
|
|
} finally {
|
|
fs.rmSync(dir, {recursive: true, force: true});
|
|
}
|
|
|
|
assert.equal(capturedData.length, 2, 'both upload attempts should reach the transport');
|
|
assert.notEqual(capturedData[0], capturedData[1], 'the retry must use a newly created FormData, not replay the first one');
|
|
|
|
const fileStreams = capturedData.map((data) => {
|
|
const streams = (data as {_streams?: unknown[]})._streams ?? [];
|
|
for (const stream of streams) {
|
|
const candidate = stream as {path?: unknown; source?: {path?: unknown}} | null;
|
|
if (typeof candidate?.path === 'string') {
|
|
return candidate as {path: string};
|
|
}
|
|
if (typeof candidate?.source?.path === 'string') {
|
|
return candidate.source as {path: string};
|
|
}
|
|
}
|
|
return undefined;
|
|
});
|
|
assert.ok(fileStreams[0] && fileStreams[1], 'each attempt should carry a file read stream');
|
|
assert.notEqual(fileStreams[0], fileStreams[1], 'the retry must use a newly created read stream');
|
|
assert.equal(fileStreams[0].path, filePath);
|
|
assert.equal(fileStreams[1].path, filePath);
|
|
});
|
|
});
|
|
|
|
const URL = 'http://unit-test.invalid/api/v4/channels';
|
|
const BODY = {name: 'channel-1'};
|
|
|
|
type Step = {code: string} | {status: number; data?: unknown};
|
|
type CallCounter = {calls: number};
|
|
|
|
// Adapter that replays `script` in order and holds the last step forever, so a single-step
|
|
// script models "fails every time". Errors are raised the way axios' http adapter raises
|
|
// them: an AxiosError carrying `code` and `config`, with `response` only for HTTP answers.
|
|
const scriptedAdapter = (script: readonly Step[], counter: CallCounter): AxiosAdapter => async (config: InternalAxiosRequestConfig) => {
|
|
const step = script[Math.min(counter.calls, script.length - 1)]!;
|
|
counter.calls++;
|
|
|
|
if ('code' in step) {
|
|
throw new AxiosError(`${step.code} ${config.url}`, step.code, config);
|
|
}
|
|
|
|
const response: AxiosResponse = {data: step.data ?? {}, status: step.status, statusText: '', headers: {}, config};
|
|
if (step.status >= 400) {
|
|
throw new AxiosError(`Request failed with status code ${step.status}`, AxiosError.ERR_BAD_REQUEST, config, undefined, response);
|
|
}
|
|
return response;
|
|
};
|
|
|
|
const post = (script: readonly Step[], counter: CallCounter) => client.post(URL, BODY, {adapter: scriptedAdapter(script, counter)});
|
|
|
|
describe('server_api client pre-connection retry', () => {
|
|
it('should classify only response-less DNS / connect failures as pre-connection', () => {
|
|
assert.equal(isPreConnectionFailure({code: 'ENOTFOUND'}), true);
|
|
assert.equal(isPreConnectionFailure({code: 'ECONNREFUSED'}), true);
|
|
assert.equal(isPreConnectionFailure({code: 'ECONNRESET'}), false, 'a reset can happen after the request was sent');
|
|
assert.equal(isPreConnectionFailure({code: 'ECONNABORTED'}), false, 'timeouts are owned by the timeout interceptor');
|
|
assert.equal(isPreConnectionFailure({code: 'ENOTFOUND', response: {status: 502}}), false, 'an HTTP answer means the request was sent');
|
|
});
|
|
|
|
it('should replay a POST after DNS failures and return the eventual answer', async () => {
|
|
const counter: CallCounter = {calls: 0};
|
|
const response = await post([{code: 'ENOTFOUND'}, {code: 'ENOTFOUND'}, {status: 201, data: {id: 'c1'}}], counter);
|
|
|
|
assert.equal(counter.calls, 3, 'two failed attempts then the success');
|
|
assert.equal(response.status, 201);
|
|
assert.deepEqual(response.data, {id: 'c1'});
|
|
});
|
|
|
|
it('should give up after PRE_CONNECTION_MAX_RETRIES and surface the original error', async () => {
|
|
const counter: CallCounter = {calls: 0};
|
|
await assert.rejects(post([{code: 'ECONNREFUSED'}], counter), (error: AxiosError) => error.code === 'ECONNREFUSED' && error.response === undefined);
|
|
|
|
assert.equal(counter.calls, 1 + PRE_CONNECTION_MAX_RETRIES, 'the first attempt plus every retry');
|
|
});
|
|
|
|
it('should not replay a POST on an ambiguous socket error', async () => {
|
|
const counter: CallCounter = {calls: 0};
|
|
await assert.rejects(post([{code: 'ECONNRESET'}], counter), (error: AxiosError) => error.code === 'ECONNRESET');
|
|
|
|
assert.equal(counter.calls, 1, 'the request may have reached the server, so it must not be replayed');
|
|
});
|
|
|
|
it('should not replay a POST the server answered', async () => {
|
|
const counter: CallCounter = {calls: 0};
|
|
await assert.rejects(post([{status: 400, data: {message: 'bad request'}}], counter), (error: AxiosError) => error.response?.status === 400);
|
|
|
|
assert.equal(counter.calls, 1);
|
|
});
|
|
});
|