diff --git a/AGENTS.md b/AGENTS.md index a37790f..3fd858f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -491,7 +491,13 @@ Thin objects over `apiClient`. `services/server-manager.ts`); every other host and every non-server-trust challenge (Basic Auth, client cert) falls through to default handling unchanged. iOS only; requires `npm run xcode` to pick up (new native code, - not just a generated-file patch). + not just a generated-file patch). `isInsecureCertAllowlistAvailable()` (#256) + reports whether the native side is actually present in the running binary — + see [§10 Gotchas](#10-gotchas) for why that can differ from the JS wrapper + loading fine. `services/server-manager.ts`'s `syncInsecureCertAllowlist` + warns via `clogWarn('CERT', …)` when a server wants the flag but it's + unavailable; the server add/edit screens show a matching hint under the + toggle. ### Hooks (`hooks/`) @@ -518,7 +524,10 @@ Pure and well-tested. **Put logic here whenever it doesn't need React.** and availability **FLOOR**, never round up) · `torrent-state.ts` (state → color/ label, completion and ETA rules) · `limit-input.ts` (share-limit sentinels: `-2` = follow global, `-1` = unlimited; own-vs-effective limit resolution) · -`error.ts` (`getErrorMessage`) · `apiVersion.ts` (parse + `ApiFeatures` gating) · +`error.ts` (`getErrorMessage`, `isTlsRejection` — recognizes iOS rejecting a +server's TLS certificate from the free-text error description RN's XHR +bridge exposes, matched across all six locales since that text is localized +to the device language — #256) · `apiVersion.ts` (parse + `ApiFeatures` gating) · `connection-settings.ts` (`resolveConnectionSettings` — resolves the axios connection timeout / retry count from raw stored preferences, falling back to `DEFAULT_PREFERENCES` on missing or corrupt values while still honoring a @@ -780,3 +789,15 @@ Keep entries factual and current; if you find one that's no longer true parameter "works" in the UI but has no visible server-side effect, check it against qBittorrent's `torrentscontroller.cpp` source, not the wiki — the wiki is not reliably kept in sync with parameter renames. +- **A feature backed by a local Expo native module (`modules/*`) can be + rendered by an OTA update on a binary that predates that module, and the + JS wrapper no-ops silently instead of erroring.** OTA JS updates ship + independently of the native binary (`app.config.js`'s `runtimeVersion.policy: + 'appVersion'` ties an OTA update to any binary on the same app version, + native code included or not), so a device can receive a feature's JS + without ever having its native half. `modules/insecure-cert-allowlist` + hit exactly this (#256): the toggle looked like it did nothing, with no + error anywhere. The fix is an explicit availability check + (`isInsecureCertAllowlistAvailable()`) that callers use to warn or hint in + the UI — don't assume a native module is present just because requiring it + didn't throw at JS-parse time. diff --git a/app/server/[id].tsx b/app/server/[id].tsx index d0e6ca0..14e201e 100644 --- a/app/server/[id].tsx +++ b/app/server/[id].tsx @@ -47,6 +47,7 @@ import { sanitizeCustomHeaders, validateCustomHeaders, } from '@/utils/customHeaders'; +import { isInsecureCertAllowlistAvailable } from '@/modules/insecure-cert-allowlist'; export default function EditServerScreen() { const router = useRouter(); @@ -890,6 +891,11 @@ App Version: ${APP_VERSION}`; {t('server.allowInsecureCertHint')} + {!isInsecureCertAllowlistAvailable() && ( + + {t('server.allowInsecureCertUnavailable')} + + )} )} diff --git a/app/server/add.tsx b/app/server/add.tsx index 25219d1..4f8b7cf 100644 --- a/app/server/add.tsx +++ b/app/server/add.tsx @@ -46,6 +46,7 @@ import { sanitizeCustomHeaders, validateCustomHeaders, } from '@/utils/customHeaders'; +import { isInsecureCertAllowlistAvailable } from '@/modules/insecure-cert-allowlist'; export default function AddServerScreen() { const router = useRouter(); @@ -810,6 +811,11 @@ App Version: ${APP_VERSION}`; {t('server.allowInsecureCertHint')} + {!isInsecureCertAllowlistAvailable() && ( + + {t('server.allowInsecureCertUnavailable')} + + )} )} diff --git a/components/SuperDebugPanel.tsx b/components/SuperDebugPanel.tsx index 1400812..9d6f6be 100644 --- a/components/SuperDebugPanel.tsx +++ b/components/SuperDebugPanel.tsx @@ -27,7 +27,7 @@ import { APP_VERSION } from '@/utils/version'; import { getConnectivityLog, formatConnectivityLog } from '@/services/connectivity-log'; import { logsApi } from '@/services/api/logs'; import { apiClient } from '@/services/api/client'; -import { getErrorMessage } from '@/utils/error'; +import { getErrorMessage, isTlsRejection } from '@/utils/error'; import { CustomHeaderPair, sanitizeCustomHeaders } from '@/utils/customHeaders'; import { isLoginBodyFail, isLoginSuccess } from '@/utils/login-response'; @@ -66,6 +66,67 @@ const diagnosticHttp = axios.create({ validateStatus: () => true, }); +/** Minimal fetch()-`Response`-shaped result for the REACH probes below — + * only `status` is ever read off it. */ +interface ReachProbeResponse { + status: number; +} + +/** + * Raw-XHR mirror of `fetch(url, { method, headers, signal })`, used only by + * the REACH probes (Feature 1 "Ping Host" and Step 1 of the full run below). + * Deliberately not routed through `diagnosticHttp` or the app's `apiClient` + * — the REACH step exists specifically to stay independent of the app's own + * HTTP stack (see this file's header comment). + * + * It replaces a plain `fetch()` call because RN's `whatwg-fetch` polyfill + * collapses every network-level failure — including a rejected TLS + * certificate — into a bare `TypeError('Network request failed')` + * (fetch.umd.js), discarding the native NSError's localizedDescription. That + * made the certificate-specific guidance below unreachable: every REACH + * failure looked like "Network request failed" regardless of cause. A raw + * XHR keeps the detail — RN's XHR bridge puts it in the response body on + * error — which is exactly how `utils/error.ts`'s `isTlsRejection` (also + * used by `services/api/client.ts`) tells a TLS rejection apart from a + * genuinely unreachable host. + */ +function reachProbeRequest( + url: string, + method: 'HEAD' | 'GET', + headers: Record, + signal: AbortSignal, +): Promise { + return new Promise((resolve, reject) => { + if (signal.aborted) { + reject(new Error('Timed out after 15s')); + return; + } + const xhr = new XMLHttpRequest(); + const onAbort = () => xhr.abort(); + const cleanup = () => signal.removeEventListener('abort', onAbort); + signal.addEventListener('abort', onAbort); + xhr.open(method, url, true); + Object.entries(headers).forEach(([key, value]) => xhr.setRequestHeader(key, value)); + xhr.onload = () => { + cleanup(); + resolve({ status: xhr.status }); + }; + xhr.onabort = () => { + cleanup(); + reject(new Error('Timed out after 15s')); + }; + xhr.onerror = () => { + cleanup(); + // Mirror axios's error shape (`error.request.response`) so + // isTlsRejection can read the native error description straight off it. + const err = new Error('Network request failed') as Error & { request?: XMLHttpRequest }; + err.request = xhr; + reject(err); + }; + xhr.send(); + }); +} + /** Reads the Set-Cookie value(s) off an axios response's headers, tolerant of * the array shape (duplicate headers) and casing quirks — mirrors the proven * extraction in services/api/client.ts's response interceptor. Returns one @@ -325,21 +386,13 @@ export function SuperDebugPanel({ const authHeader = buildAuthHeader(); const reachHeaders: Record = { ...buildCustomHeaders() }; if (authHeader) reachHeaders['Authorization'] = authHeader; - let response: Response; + let response: ReachProbeResponse; try { - response = await fetch(url, { - method: 'HEAD', - headers: reachHeaders, - signal: controller.signal, - }); + response = await reachProbeRequest(url, 'HEAD', reachHeaders, controller.signal); } catch { // Some servers reject HEAD — fall back to GET if (controller.signal.aborted) throw new Error('Timed out after 15s'); - response = await fetch(url, { - method: 'GET', - headers: reachHeaders, - signal: controller.signal, - }); + response = await reachProbeRequest(url, 'GET', reachHeaders, controller.signal); } const latency = Date.now() - start; @@ -367,23 +420,28 @@ export function SuperDebugPanel({ const msg = getErrorMessage(err) || 'Unknown error'; addEntry('REACH', `Host unreachable after ${latency}ms`, 'error'); - // Provide specific guidance based on error type - if (msg.includes('Network request failed') || msg.includes('Failed to connect')) { + // Provide specific guidance based on error type. The TLS check runs + // first: a rejected certificate reaches here as the same generic + // "Network request failed" text a genuinely unreachable host produces + // (see reachProbeRequest's onerror above), so isTlsRejection — which + // inspects the XHR's response body for the native error description + // rather than the generic message — is the only way to tell them apart. + if (isTlsRejection(err)) { addEntry( 'WARN', - 'The device cannot reach the server at all. Possible causes:\n 1. IP address or domain is wrong\n 2. Server is off or qBittorrent is not running\n 3. Port is incorrect (qBittorrent default: 8080)\n 4. Firewall is blocking the connection\n 5. If remote: VPN/port forwarding not configured', + 'The server was reached, but iOS rejected its TLS certificate. If you do not have HTTPS set up, turn off the "Use HTTPS" toggle. If you are intentionally using a self-signed certificate, enable "Allow Self-Signed Certificate" in the Security section above — trusting the certificate on this device alone is not enough for a third-party app to accept it.', 'warning', ); - } else if (msg.includes('Timed out') || msg.includes('timeout') || msg.includes('aborted')) { + } else if (msg.includes('Network request failed') || msg.includes('Failed to connect')) { addEntry( 'WARN', - 'Connection timed out. The server did not respond within 15 seconds. Possible causes:\n 1. Server is behind a firewall that silently drops packets\n 2. Wrong port (packets go nowhere)\n 3. Network latency too high (weak connection)', + 'The device cannot reach the server at all. Possible causes:\n 1. IP address or domain is wrong\n 2. Server is off or qBittorrent is not running\n 3. Port is incorrect (qBittorrent default: 8080)\n 4. Firewall is blocking the connection\n 5. If remote: VPN/port forwarding not configured', 'warning', ); - } else if (msg.includes('SSL') || msg.includes('certificate') || msg.includes('TLS')) { + } else if (msg.includes('Timed out') || msg.includes('timeout') || msg.includes('aborted')) { addEntry( 'WARN', - 'The server was reached, but iOS rejected its TLS certificate. If you do not have HTTPS set up, turn off the "Use HTTPS" toggle. If you are intentionally using a self-signed certificate, enable "Allow Self-Signed Certificate" in the Security section above — trusting the certificate on this device alone is not enough for a third-party app to accept it.', + 'Connection timed out. The server did not respond within 15 seconds. Possible causes:\n 1. Server is behind a firewall that silently drops packets\n 2. Wrong port (packets go nowhere)\n 3. Network latency too high (weak connection)', 'warning', ); } else { @@ -521,20 +579,12 @@ export function SuperDebugPanel({ } try { - let reachResp: Response; + let reachResp: ReachProbeResponse; try { - reachResp = await fetch(baseUrl, { - method: 'HEAD', - headers: diagHeaders, - signal: controller.signal, - }); + reachResp = await reachProbeRequest(baseUrl, 'HEAD', diagHeaders, controller.signal); } catch { if (controller.signal.aborted) throw new Error('Timed out after 15s'); - reachResp = await fetch(baseUrl, { - method: 'GET', - headers: diagHeaders, - signal: controller.signal, - }); + reachResp = await reachProbeRequest(baseUrl, 'GET', diagHeaders, controller.signal); } clearTimeout(reachTimeout); const reachLatency = Date.now() - reachStart; @@ -560,7 +610,16 @@ export function SuperDebugPanel({ const msg = getErrorMessage(err) || 'Unknown error'; addEntry('REACH', `FAILED — Server unreachable after ${reachLatency}ms`, 'error'); - if (msg.includes('Network request failed') || msg.includes('Failed to connect')) { + // TLS check first — see the matching comment on the Feature 1 probe + // above; the same generic "Network request failed" text covers both + // a rejected certificate and a genuinely unreachable host here. + if (isTlsRejection(err)) { + addEntry( + 'WARN', + 'The server was reached, but iOS rejected its TLS certificate. If you do not have HTTPS configured, turn off the "Use HTTPS" toggle. If you are intentionally using a self-signed certificate, enable "Allow Self-Signed Certificate" in the Security section above — trusting the certificate on this device alone is not enough for a third-party app to accept it.', + 'warning', + ); + } else if (msg.includes('Network request failed') || msg.includes('Failed to connect')) { addEntry( 'WARN', 'Your device cannot establish a connection to this address.\n\nChecklist:\n 1. Is the IP/domain correct?\n 2. Is qBittorrent running with WebUI enabled?\n 3. Is the port correct? (default: 8080)\n 4. Is a firewall blocking the connection?\n 5. If accessing remotely: is port forwarding or VPN set up?\n 6. Try "Open WebUI" above to test in a browser.', @@ -576,12 +635,6 @@ export function SuperDebugPanel({ 'The server did not respond within 15 seconds.\n\nThis usually means:\n 1. A firewall is silently dropping packets\n 2. The port is wrong (nothing is listening)\n 3. The server is too slow or overloaded\n\nTry "Open WebUI" above to verify in a browser.', 'warning', ); - } else if (msg.includes('SSL') || msg.includes('certificate') || msg.includes('TLS')) { - addEntry( - 'WARN', - 'The server was reached, but iOS rejected its TLS certificate. If you do not have HTTPS configured, turn off the "Use HTTPS" toggle. If you are intentionally using a self-signed certificate, enable "Allow Self-Signed Certificate" in the Security section above — trusting the certificate on this device alone is not enough for a third-party app to accept it.', - 'warning', - ); } else { addEntry('WARN', `Error: ${msg}`, 'warning'); } diff --git a/locales/de/translation.json b/locales/de/translation.json index d62a255..7eb8923 100644 --- a/locales/de/translation.json +++ b/locales/de/translation.json @@ -776,6 +776,7 @@ "useHttps": "HTTPS verwenden", "allowInsecureCert": "Nicht vertrauenswürdiges, selbstsigniertes Zertifikat zulassen", "allowInsecureCertHint": "Akzeptiert das Zertifikat dieses Servers, auch wenn iOS ihm nicht vertraut. Aktiviere dies nur für einen Server, den du selbst kontrollierst — der Schutz vor einer gefälschten oder abgefangenen Verbindung entfällt dadurch.", + "allowInsecureCertUnavailable": "Erfordert die neueste Version aus dem App Store.", "authMethod": "Authentifizierungsmethode", "authMethodPassword": "Benutzername und Passwort", "authMethodApiKey": "API-Schlüssel", diff --git a/locales/en/translation.json b/locales/en/translation.json index 0734e69..a177053 100644 --- a/locales/en/translation.json +++ b/locales/en/translation.json @@ -797,6 +797,7 @@ "useHttps": "Use HTTPS", "allowInsecureCert": "Allow Untrusted, Self-Signed Certificate", "allowInsecureCertHint": "Accepts this server's certificate even if iOS does not trust it. Only enable this for a server you control — it removes protection against a spoofed or intercepted connection.", + "allowInsecureCertUnavailable": "Requires the latest App Store version.", "authMethod": "Authentication Method", "authMethodPassword": "Username & Password", "authMethodApiKey": "API Key", diff --git a/locales/es/translation.json b/locales/es/translation.json index 9236dab..8daf507 100644 --- a/locales/es/translation.json +++ b/locales/es/translation.json @@ -776,6 +776,7 @@ "useHttps": "Usar HTTPS", "allowInsecureCert": "Permitir certificado autofirmado y no confiable", "allowInsecureCertHint": "Acepta el certificado de este servidor aunque iOS no confíe en él. Actívalo solo para un servidor que controles: elimina la protección frente a una conexión suplantada o interceptada.", + "allowInsecureCertUnavailable": "Requiere la última versión de la App Store.", "authMethod": "Método de autenticación", "authMethodPassword": "Usuario y contraseña", "authMethodApiKey": "Clave de API", diff --git a/locales/fr/translation.json b/locales/fr/translation.json index 1a6b797..5298f61 100644 --- a/locales/fr/translation.json +++ b/locales/fr/translation.json @@ -776,6 +776,7 @@ "useHttps": "Utiliser HTTPS", "allowInsecureCert": "Autoriser un certificat auto-signé non approuvé", "allowInsecureCertHint": "Accepte le certificat de ce serveur même si iOS ne lui fait pas confiance. N'activez ceci que pour un serveur que vous contrôlez : cela supprime la protection contre une connexion usurpée ou interceptée.", + "allowInsecureCertUnavailable": "Nécessite la dernière version de l'App Store.", "authMethod": "Méthode d'authentification", "authMethodPassword": "Nom d'utilisateur et mot de passe", "authMethodApiKey": "Clé API", diff --git a/locales/ru/translation.json b/locales/ru/translation.json index ca184d9..dcfc914 100644 --- a/locales/ru/translation.json +++ b/locales/ru/translation.json @@ -797,6 +797,7 @@ "useHttps": "Использовать HTTPS", "allowInsecureCert": "Разрешить недоверенный самоподписанный сертификат", "allowInsecureCertHint": "Принимает сертификат этого сервера, даже если iOS ему не доверяет. Включайте только для сервера, который контролируете вы сами — это отключает защиту от подмены или перехвата соединения.", + "allowInsecureCertUnavailable": "Требуется последняя версия из App Store.", "authMethod": "Способ авторизации", "authMethodPassword": "Имя пользователя и пароль", "authMethodApiKey": "API-ключ", diff --git a/locales/zh/translation.json b/locales/zh/translation.json index 1bb8b0d..cb81c04 100644 --- a/locales/zh/translation.json +++ b/locales/zh/translation.json @@ -776,6 +776,7 @@ "useHttps": "使用 HTTPS", "allowInsecureCert": "允许不受信任的自签名证书", "allowInsecureCertHint": "即使 iOS 不信任此服务器的证书,也接受该证书。仅对您自己掌控的服务器启用此选项——它会移除对伪造或被拦截连接的防护。", + "allowInsecureCertUnavailable": "需要最新的 App Store 版本。", "authMethod": "认证方式", "authMethodPassword": "用户名和密码", "authMethodApiKey": "API 密钥", diff --git a/modules/insecure-cert-allowlist/index.ts b/modules/insecure-cert-allowlist/index.ts index 0203d14..bba1c1b 100644 --- a/modules/insecure-cert-allowlist/index.ts +++ b/modules/insecure-cert-allowlist/index.ts @@ -35,3 +35,22 @@ try { export function setInsecureCertAllowedHosts(hosts: string[]): void { nativeModule?.setAllowedHosts(hosts); } + +/** + * True only when the native module is actually linked into this running + * binary — not just when the JS wrapper above loaded without throwing. + * + * OTA (over-the-air) JS updates ship independently of the native binary + * (`app.config.js`'s `runtimeVersion.policy: 'appVersion'` ties an OTA + * update to *any* binary on the same app version, native code included or + * not). So a device that installed this feature's JS via an OTA update, but + * whose binary predates the native module being added, gets a + * `nativeModule` of `null` here forever — `setInsecureCertAllowedHosts` + * above silently no-ops, and the "Allow Untrusted, Self-Signed Certificate" + * toggle looks like it does nothing. Callers use this flag to warn (see + * `services/server-manager.ts`'s `syncInsecureCertAllowlist`) or hint in the + * UI instead of failing silently (#256). + */ +export function isInsecureCertAllowlistAvailable(): boolean { + return nativeModule !== null; +} diff --git a/services/api/client.ts b/services/api/client.ts index d67b223..a7b45dc 100644 --- a/services/api/client.ts +++ b/services/api/client.ts @@ -7,6 +7,7 @@ import axios, { AxiosInstance, AxiosError, AxiosHeaders, InternalAxiosRequestCon import { ServerConfig } from '@/types/api'; import { clogDebug, clogInfo, clogWarn, clogError } from '@/services/connectivity-log'; import { ApiFeatures, getApiFeatures } from '@/utils/apiVersion'; +import { isTlsRejection } from '@/utils/error'; import { basicAuthHeader } from '@/utils/basicAuth'; import { isReservedHeaderName } from '@/utils/customHeaders'; @@ -243,6 +244,21 @@ class ApiClient { // Handle network errors if (error.code === 'ECONNABORTED' || error.code === 'ERR_NETWORK') { + // iOS's TLS-certificate rejection (NSURLErrorServerCertificateUntrusted, + // -1202, and friends) surfaces here too — as a plain ERR_NETWORK with + // no distinguishing code (#256). Without this, a rejected self-signed + // certificate is indistinguishable from a genuinely dead server, so + // nobody can tell what's wrong from the app alone. isTlsRejection reads + // the native error description RN stashes on the XHR (see utils/error.ts) + // to tell the two apart. This is a *new*, separate message — do not fold + // it into 'Connection timeout...' below, which callers substring-match. + if (isTlsRejection(error)) { + clogWarn('TLS', `Certificate rejected — ${reqUrl}`); + throw apiError( + 'Certificate rejected. Enable "Allow Untrusted, Self-Signed Certificate" for this server if you trust it.', + status, + ); + } clogError('HTTP', `Network error (${error.code}) — ${reqUrl}`); throw apiError('Connection timeout. Please check your server connection.', status); } diff --git a/services/server-manager.ts b/services/server-manager.ts index 75b0e97..2ac1d10 100644 --- a/services/server-manager.ts +++ b/services/server-manager.ts @@ -14,7 +14,10 @@ import { apiClient } from './api/client'; import { authApi } from './api/auth'; import { applicationApi } from './api/application'; import { clogInfo, clogWarn, clogError } from './connectivity-log'; -import { setInsecureCertAllowedHosts } from '@/modules/insecure-cert-allowlist'; +import { + setInsecureCertAllowedHosts, + isInsecureCertAllowlistAvailable, +} from '@/modules/insecure-cert-allowlist'; /** * Pushes every host opted into `allowInsecureCert` to the native TLS @@ -23,8 +26,18 @@ import { setInsecureCertAllowedHosts } from '@/modules/insecure-cert-allowlist'; * change before the next connection attempt. */ function syncInsecureCertAllowlist(servers: ServerConfig[]): void { - const hosts = servers - .filter((s) => s.allowInsecureCert) + const wantAllowlist = servers.filter((s) => s.allowInsecureCert); + // A server can have this flag set while the native module is absent from + // the running binary (OTA JS on a pre-#256 binary — see + // modules/insecure-cert-allowlist/index.ts). The toggle silently no-ops in + // that case; warn so a connection failure doesn't look unexplained. + if (wantAllowlist.length > 0 && !isInsecureCertAllowlistAvailable()) { + clogWarn( + 'CERT', + `${wantAllowlist.length} server(s) have "Allow Untrusted, Self-Signed Certificate" enabled, but this build has no native allowlist module — the toggle will not take effect until the app is updated from the App Store.`, + ); + } + const hosts = wantAllowlist .flatMap((s) => [s.host, s.fallbackHost]) .filter((h): h is string => !!h); setInsecureCertAllowedHosts(hosts); diff --git a/tests/services/client.test.ts b/tests/services/client.test.ts index a5d613f..bb7a2db 100644 --- a/tests/services/client.test.ts +++ b/tests/services/client.test.ts @@ -49,6 +49,7 @@ class MockAxiosError extends Error { code?: string; config?: Record; response?: { status?: number; data?: unknown; headers?: Record }; + request?: { response?: unknown }; isAxiosError = true; constructor(message?: string) { super(message); @@ -430,6 +431,26 @@ describe('apiClient', () => { ); }); + it('normalizes an ERR_NETWORK carrying a TLS-rejection description to a distinct certificate error (#256)', () => { + const err = makeErr({ + code: 'ERR_NETWORK', + request: { + response: + 'The certificate for this server is invalid. You might be connecting to a server that is pretending to be "example.com".', + }, + }); + expect(() => capturedResponseInterceptorError!(err)).toThrow( + 'Certificate rejected. Enable "Allow Untrusted, Self-Signed Certificate" for this server if you trust it.', + ); + }); + + it('does not mistake an ordinary ERR_NETWORK for a TLS rejection (#256)', () => { + const err = makeErr({ code: 'ERR_NETWORK', request: { response: '' } }); + expect(() => capturedResponseInterceptorError!(err)).toThrow( + 'Connection timeout. Please check your server connection.', + ); + }); + it('normalizes ERR_CANCELED to a distinct canceled error, not the timeout message (#254)', () => { const err = makeErr({ code: 'ERR_CANCELED' }); expect(() => capturedResponseInterceptorError!(err)).toThrow('Request canceled.'); diff --git a/tests/utils/error.test.ts b/tests/utils/error.test.ts index 4f42287..126217a 100644 --- a/tests/utils/error.test.ts +++ b/tests/utils/error.test.ts @@ -1,5 +1,5 @@ import { AxiosError } from 'axios'; -import { getErrorMessage, isAxiosError } from '@/utils/error'; +import { getErrorMessage, isAxiosError, isTlsRejection } from '@/utils/error'; describe('getErrorMessage', () => { it('returns the message of an Error instance', () => { @@ -38,3 +38,63 @@ describe('isAxiosError', () => { expect(isAxiosError({ isAxiosError: true })).toBe(false); }); }); + +describe('isTlsRejection', () => { + it('recognizes an English TLS-rejection description on error.request.response', () => { + const err = { + message: 'Network Error', + request: { + response: + 'The certificate for this server is invalid. You might be connecting to a server that is pretending to be "example.com" which could put your confidential information at risk.', + }, + }; + expect(isTlsRejection(err)).toBe(true); + }); + + it('recognizes a non-English (Spanish) description, since the text is localized (#256)', () => { + const err = { + message: 'Network Error', + request: { + response: 'El certificado de este servidor no es válido.', + }, + }; + expect(isTlsRejection(err)).toBe(true); + }); + + it('recognizes a language-neutral SSL/TLS keyword even without the word "certificate"', () => { + const err = { message: 'An SSL error occurred while establishing a connection.' }; + expect(isTlsRejection(err)).toBe(true); + }); + + it('falls back to error.message when error.request.response is absent', () => { + const err = new Error('The certificate for this server is invalid.'); + expect(isTlsRejection(err)).toBe(true); + }); + + it('is case-insensitive', () => { + const err = { message: 'TLS handshake failed' }; + expect(isTlsRejection(err)).toBe(true); + }); + + it('returns false for a generic network error with no cert wording', () => { + const err = { message: 'Network Error', request: { response: '' } }; + expect(isTlsRejection(err)).toBe(false); + }); + + it('returns false for a plain timeout', () => { + expect( + isTlsRejection(new Error('Connection timeout. Please check your server connection.')), + ).toBe(false); + }); + + it('returns false for non-object values', () => { + expect(isTlsRejection('string')).toBe(false); + expect(isTlsRejection(null)).toBe(false); + expect(isTlsRejection(undefined)).toBe(false); + }); + + it('ignores a non-string error.request.response', () => { + const err = { message: 'Network Error', request: { response: { some: 'object' } } }; + expect(isTlsRejection(err)).toBe(false); + }); +}); diff --git a/utils/error.ts b/utils/error.ts index db83c96..9cb42b7 100644 --- a/utils/error.ts +++ b/utils/error.ts @@ -21,3 +21,51 @@ export function getErrorStatus(error: unknown): number | undefined { } return undefined; } + +/** + * "certificate" in each of the six locales this app ships (see + * locales/*\/translation.json), plus the language-neutral technical terms + * "SSL"/"TLS" that iOS's error descriptions tend to carry regardless of + * device language. Used to recognize a TLS-rejection failure (NSURLError + * -1202 and friends) from free-text error descriptions that are localized + * to the device's language — matching only the English word would miss + * most non-English users. + */ +const TLS_REJECTION_KEYWORDS = [ + 'ssl', + 'tls', + 'certificate', // en + 'certificado', // es + '证书', // zh + 'certificat', // fr + 'zertifikat', // de + 'сертификат', // ru +]; + +/** + * Best-effort text to sniff for TLS-rejection wording. iOS's rejected-cert + * failure surfaces to JS as a plain ERR_NETWORK with no error code + * preserved — the useful detail (the native NSError's localizedDescription) + * is not on the error at all, but React Native's XHR bridge stashes it in + * the response body on error instead of forwarding the NSURLErrorDomain + * code (see XMLHttpRequest.js / RCTNetworking.mm). Axios attaches that XHR + * as `error.request` and leaves `error.request.response` holding that text + * for a non-JSON request, so that's checked first; `.message` is the + * fallback for callers that don't go through axios's XHR adapter. + */ +function extractErrorText(error: unknown): string { + if (!error || typeof error !== 'object') return ''; + const request = (error as { request?: { response?: unknown } }).request; + const responseText = request && typeof request.response === 'string' ? request.response : ''; + if (responseText) return responseText; + const message = (error as { message?: unknown }).message; + return typeof message === 'string' ? message : ''; +} + +/** True when `error` looks like iOS rejecting the server's TLS certificate + * rather than a genuinely unreachable server — see `extractErrorText`. */ +export function isTlsRejection(error: unknown): boolean { + const text = extractErrorText(error).toLowerCase(); + if (!text) return false; + return TLS_REJECTION_KEYWORDS.some((keyword) => text.includes(keyword)); +}