From adc0ef63e1e0b02a746c891a70c9531ab5975d8f Mon Sep 17 00:00:00 2001 From: ZaychenkoAA Date: Mon, 2 Nov 2026 13:59:12 +0700 Subject: [PATCH 1/2] Add tests for the backup API and update the request-handling logic --- .github/workflows/build.yml | 2 + package.json | 1 + scripts/api-fallback.test.mjs | 210 ++++++++++++++++++++++++++++++++++ src/api/client.ts | 48 +++++++- 4 files changed, 256 insertions(+), 5 deletions(-) create mode 100644 scripts/api-fallback.test.mjs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c58dc09..d07d737 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -283,6 +283,7 @@ jobs: - name: Type-check frontend run: | npm run check:window-scale-config + npm run test:api-fallback npx tsc --noEmit - name: Check and test Rust core @@ -549,6 +550,7 @@ jobs: - name: Type-check frontend run: | npm run check:window-scale-config + npm run test:api-fallback npx tsc --noEmit - name: Test Windows Rust core diff --git a/package.json b/package.json index fb87b44..60bb755 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "version": "1.0.97", "type": "module", "scripts": { + "test:api-fallback": "node --test scripts/api-fallback.test.mjs", "check:window-scale-config": "node scripts/check-window-scale-config.mjs", "inject-host": "bash scripts/inject-backend-host.sh", "restore-host": "bash scripts/restore-placeholder.sh", diff --git a/scripts/api-fallback.test.mjs b/scripts/api-fallback.test.mjs new file mode 100644 index 0000000..07a0e86 --- /dev/null +++ b/scripts/api-fallback.test.mjs @@ -0,0 +1,210 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import vm from "node:vm"; +import ts from "typescript"; + +// Run the production client with an in-memory session and HTTP transport. +// No real endpoints or credentials are used by these regression tests. +const source = await readFile(new URL("../src/api/client.ts", import.meta.url), "utf8"); +const compiled = ts.transpileModule(source.replaceAll("import.meta.env.DEV", "false"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, +}).outputText; +const PRIMARY = "https://primary.test/"; +const FALLBACK = "https://fallback.test/function?region=test&u="; +const tokens = { + access_token: "renewed-access", + refresh_token: "renewed-refresh", + expires_in: 1800, + refresh_expires_in: 86400, + is_linked: true, +}; +const json = (body, status = 200) => Response.json(body, { status }); +const tokenResponse = () => json({ success: true, data: tokens }); + +function createClient({ fetch, expired = false, fallback = FALLBACK }) { + let session = { + deviceId: "test-device", + isLinked: true, + accessToken: "initial-access", + refreshToken: "initial-refresh", + accessTokenExpiresAt: Date.now() + (expired ? -1000 : 60000), + refreshTokenExpiresAt: Date.now() + 86400000, + }; + const calls = []; + const diagnostics = []; + let cleared = 0; + const transport = async (rawUrl, init) => { + const url = new URL(rawUrl); + const isFallback = url.hostname === "fallback.test"; + const target = isFallback ? new URL(`https://${url.searchParams.get("u")}`) : url; + const call = { + url, target, isFallback, method: init.method ?? "GET", + headers: new Headers(init.headers), body: init.body, + }; + calls.push(call); + return fetch(call); + }; + const imports = { + "@tauri-apps/plugin-http": { fetch: transport }, + "./config": { + BOT_API_BASE_URL: PRIMARY, + BOT_API_FALLBACK_URL: fallback, + CONTROL_PLANE_BYPASS_HOSTS: ["primary.test", "fallback.test"], + }, + "../session/fingerprint": { + getDeviceFingerprint: async () => ({ hwid: "test-hwid", userAgent: "ToBeVPN/test" }), + }, + "../session/diagnostics": { + recordDiagnosticEvent: (...event) => diagnostics.push(event), + }, + "../session/store": { + getSession: () => session, + getSessionGeneration: () => 1, + hasValidAccessToken: (value) => Boolean(value.accessToken && value.accessTokenExpiresAt > Date.now()), + hasValidRefreshToken: (value) => Boolean(value.refreshToken && value.refreshTokenExpiresAt > Date.now()), + updateSession: (patch) => (session = { ...session, ...patch }), + updateSessionFromTokens: (value) => (session = { + ...session, + accessToken: value.access_token, + refreshToken: value.refresh_token, + accessTokenExpiresAt: Date.now() + value.expires_in * 1000, + refreshTokenExpiresAt: Date.now() + value.refresh_expires_in * 1000, + }), + clearSessionTokens: () => { + cleared += 1; + session = { ...session, accessToken: null, refreshToken: null }; + }, + }, + "../session/secureSession": { + clearSecureSession: async () => {}, + persistCurrentSessionSecrets: async () => {}, + }, + }; + const exports = {}; + vm.runInNewContext(compiled, { + exports, + require: (name) => { + assert.ok(Object.hasOwn(imports, name), `Unexpected import: ${name}`); + return imports[name]; + }, + URL, Headers, Response, AbortController, TextEncoder, crypto, performance, Error, + console: { warn() {} }, + clearTimeout, + window: { setTimeout: (callback, ms) => setTimeout(callback, ms === 400 ? 0 : ms) }, + }, { filename: "src/api/client.ts" }); + return { api: exports, calls, diagnostics, session: () => session, cleared: () => cleared }; +} + +test("expired session refreshes through fallback before an authenticated GET", async () => { + const client = createClient({ + expired: true, + fetch: (call) => { + if (!call.isFallback) throw new Error("Primary network unavailable"); + if (call.target.pathname === "/api/device/refresh") return tokenResponse(); + assert.equal(call.headers.get("X-Proxy-Authorization"), "Bearer renewed-access"); + assert.equal(call.headers.has("Authorization"), false); + return json({ response: [] }); + }, + }); + const result = await client.api.getNodes(); + assert.deepEqual(result, { response: [] }); + const refresh = client.calls.filter((call) => call.target.pathname === "/api/device/refresh"); + assert.equal(refresh.length, 2); + assert.deepEqual(refresh.map((call) => call.isFallback), [false, true]); + assert.equal(refresh[1].method, "POST"); + assert.equal(refresh[1].body, refresh[0].body); + assert.deepEqual(JSON.parse(refresh[1].body), { refresh_token: "initial-refresh" }); + assert.equal(refresh[1].url.searchParams.get("region"), "test"); + assert.equal(refresh[1].url.searchParams.get("u"), "primary.test/api/device/refresh"); + assert.equal(client.session().accessToken, "renewed-access"); + assert.equal(client.cleared(), 0); +}); + +test("bootstrap falls back with the same device identity and JSON body", async () => { + const client = createClient({ fetch: (call) => { + if (!call.isFallback) throw new Error("Primary network unavailable"); + return tokenResponse(); + } }); + await client.api.bootstrapDeviceSession(); + assert.equal(client.calls.length, 2); + assert.equal(client.calls[1].method, "POST"); + assert.equal(client.calls[1].body, client.calls[0].body); + assert.deepEqual(JSON.parse(client.calls[1].body), { device_id: "test-device", platform: "desktop" }); + assert.equal(client.calls[1].headers.get("Content-Type"), "application/json"); +}); + +test("a primary HTTP 403 can use the session fallback", async () => { + const client = createClient({ fetch: (call) => call.isFallback + ? tokenResponse() + : json({ message: "Route blocked" }, 403) }); + await client.api.bootstrapDeviceSession(); + assert.deepEqual(client.calls.map((call) => call.isFallback), [false, true]); +}); + +test("an authoritative primary HTTP 401 is not replayed through fallback", async () => { + const client = createClient({ fetch: () => json({ message: "Invalid session" }, 401) }); + await assert.rejects(client.api.bootstrapDeviceSession(), { status: 401 }); + assert.equal(client.calls.length, 1); +}); + +test("writes use the known fallback route once, with proxy authorization", async () => { + const client = createClient({ fetch: (call) => { + if (!call.isFallback) throw new Error("Primary network unavailable"); + return json({ success: true, response: [] }); + } }); + await client.api.getNodes(); + client.calls.length = 0; + const body = { device_name: "test PC", device_type: "pc", platform: "windows" }; + await client.api.registerDevice(body); + assert.equal(client.calls.length, 1); + const [call] = client.calls; + assert.equal(call.isFallback, true); + assert.equal(call.method, "POST"); + assert.deepEqual(JSON.parse(call.body), body); + assert.equal(call.headers.get("X-Proxy-Authorization"), "Bearer initial-access"); + assert.equal(call.headers.has("Authorization"), false); +}); + +test("a failed mutation is not replayed on another route", async () => { + const client = createClient({ fetch: () => { throw new Error("Response lost"); } }); + await assert.rejects(client.api.unlinkDevice({ device_id: "another-device" }), /Network request failed/); + assert.equal(client.calls.length, 1); +}); + +test("a failed write on the known fallback is not replayed on primary", async () => { + const client = createClient({ fetch: (call) => { + if (!call.isFallback || call.method === "POST") throw new Error("Response lost"); + return json({ response: [] }); + } }); + await client.api.getNodes(); + client.calls.length = 0; + await assert.rejects(client.api.unlinkDevice({ device_id: "another-device" }), /Network request failed/); + assert.equal(client.calls.length, 1); + assert.equal(client.calls[0].isFallback, true); +}); + +test("fallback transport failure preserves the stored session", async () => { + const client = createClient({ expired: true, fetch: () => { throw new Error("Network unavailable"); } }); + await assert.rejects(client.api.ensureDeviceSession(), /Network request failed/); + assert.equal(client.calls.length, 2); + assert.equal(client.cleared(), 0); + assert.equal(client.session().refreshToken, "initial-refresh"); +}); + +test("a technical proxy HTTP 403 does not invalidate the refresh token", async () => { + const client = createClient({ expired: true, fetch: (call) => { + if (!call.isFallback) throw new Error("Primary network unavailable"); + return json({ errorCode: 403, errorMessage: "Forbidden: Not authorized", errorType: "ClientError" }, 403); + } }); + await assert.rejects(client.api.ensureDeviceSession(), /Fallback route rejected request/); + assert.equal(client.calls.length, 2); + assert.equal(client.cleared(), 0); +}); + +test("a missing fallback keeps a single primary session request", async () => { + const client = createClient({ fallback: null, expired: true, fetch: () => { throw new Error("Network unavailable"); } }); + await assert.rejects(client.api.ensureDeviceSession(), /Network request failed/); + assert.equal(client.calls.length, 1); + assert.equal(client.cleared(), 0); +}); diff --git a/src/api/client.ts b/src/api/client.ts index 821c0fc..3f0acad 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -460,8 +460,26 @@ async function performFetchRoute( : null; const method = (init.method ?? "GET").toUpperCase(); const isSafeMethod = method === "GET" || method === "HEAD"; + // Session acquisition must remain available when only the proxy is reachable. + // Keep these POSTs sequential: racing refreshes can rotate the same token twice. + const isSessionRequest = method === "POST" && [ + "api/device/bootstrap", + "api/device/refresh", + ].includes(path.replace(/^\/+/, "")); const diagnosticRoute = diagnosticApiRoute(path); + if (fallbackUrl && !isSafeMethod && primaryUnavailableUntil > performance.now()) { + // Select the known reachable route before sending a mutation. Never replay + // it on primary if the fallback response is lost after committing the write. + recordDiagnosticEvent( + "API-Route", + `Write request using fallback route; route=${diagnosticRoute}`, + "D", + ); + const response = await attemptFetch(fallbackUrl, fallbackInit, FALLBACK_TIMEOUT_MS, userSignal); + return await rejectProxyGatewayAuthError(response); + } + // Endpoints that explicitly require a successful response must not inherit // the global fallback-first cooldown. The fallback proxy can legitimately // lack a newer/account-specific route (for example purchase plans) and @@ -496,21 +514,26 @@ async function performFetchRoute( ); } + let primaryResponse: Response; try { - return await attemptFetch( + primaryResponse = await attemptFetch( primaryUrl, primaryInit, BOT_API_FALLBACK_URL ? PRIMARY_TIMEOUT_MS : REQUEST_TIMEOUT_MS, userSignal, ); } catch (primaryError) { - // Retrying a state-changing request can duplicate an unlink, purchase, - // or registration when the primary committed it but its response was lost. - if (!BOT_API_FALLBACK_URL || !isSafeMethod) throw primaryError; if (userSignal?.aborted) throw primaryError; - console.warn(`[bot-api] primary request to ${path} failed, retrying via fallback`); if (!fallbackUrl) throw primaryError; primaryUnavailableUntil = performance.now() + PRIMARY_FAILURE_COOLDOWN_MS; + // Only session acquisition may retry a POST. Replaying another mutation + // could duplicate an unlink or activation after a lost primary response. + if (!isSafeMethod && !isSessionRequest) throw primaryError; + recordDiagnosticEvent( + "API-Route", + `Primary transport failed; retrying via fallback; route=${diagnosticRoute}`, + "W", + ); const response = await attemptFetch( fallbackUrl, fallbackInit, @@ -519,6 +542,21 @@ async function performFetchRoute( ); return await rejectProxyGatewayAuthError(response); } + + if (!fallbackUrl || !isSessionRequest || primaryResponse.status !== FALLBACK_HTTP_STATUS) { + return primaryResponse; + } + // Restricted networks can answer with 403 instead of a transport error, + // including on the bootstrap/refresh endpoints (as on the Android client). + recordDiagnosticEvent( + "API-Route", + `Primary session route returned HTTP 403; retrying via fallback; route=${diagnosticRoute}`, + "W", + ); + const response = await attemptFetch(fallbackUrl, fallbackInit, FALLBACK_TIMEOUT_MS, userSignal); + const validated = await rejectProxyGatewayAuthError(response); + if (validated.ok) primaryUnavailableUntil = performance.now() + PRIMARY_FAILURE_COOLDOWN_MS; + return validated; } async function performFetch( From 01427aa3c51bec8c14dc4713373863b2db72e033 Mon Sep 17 00:00:00 2001 From: Shoolife Date: Mon, 2 Nov 2026 10:16:58 +0300 Subject: [PATCH 2/2] Retry primary when the fallback proxy refuses a write During the fallback-first cooldown a write went only to the fallback. The proxy can lack newer routes and answer 400/404/405, or refuse with its own gateway 403; the write never reached the backend, so it is now sent once to the primary. A lost fallback response is still not replayed, since the write may have landed. Co-Authored-By: Claude Opus 5.5 --- scripts/api-fallback.test.mjs | 33 ++++++++++++++++++++++++++++++++ src/api/client.ts | 36 ++++++++++++++++++++++++++++++++++- 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/scripts/api-fallback.test.mjs b/scripts/api-fallback.test.mjs index 07a0e86..0eefba8 100644 --- a/scripts/api-fallback.test.mjs +++ b/scripts/api-fallback.test.mjs @@ -166,6 +166,39 @@ test("writes use the known fallback route once, with proxy authorization", async assert.equal(call.headers.has("Authorization"), false); }); +test("a write the fallback proxy cannot route is retried once on primary", async () => { + const client = createClient({ fetch: (call) => { + if (call.method === "GET") { + if (!call.isFallback) throw new Error("Primary network unavailable"); + return json({ response: [] }); + } + return call.isFallback ? json({ message: "Unknown route" }, 404) : json({ success: true }); + } }); + await client.api.getNodes(); + client.calls.length = 0; + const body = { device_name: "test PC", device_type: "pc", platform: "windows" }; + await client.api.registerDevice(body); + assert.deepEqual(client.calls.map((call) => call.isFallback), [true, false]); + assert.deepEqual(JSON.parse(client.calls[1].body), body); + assert.equal(client.calls[1].headers.get("Authorization"), "Bearer initial-access"); +}); + +test("a gateway 403 on a fallback write is retried once on primary", async () => { + const client = createClient({ fetch: (call) => { + if (call.method === "GET") { + if (!call.isFallback) throw new Error("Primary network unavailable"); + return json({ response: [] }); + } + return call.isFallback + ? json({ errorCode: 403, errorMessage: "Forbidden: Not authorized", errorType: "ClientError" }, 403) + : json({ success: true }); + } }); + await client.api.getNodes(); + client.calls.length = 0; + await client.api.registerDevice({ device_name: "test PC", device_type: "pc", platform: "windows" }); + assert.deepEqual(client.calls.map((call) => call.isFallback), [true, false]); +}); + test("a failed mutation is not replayed on another route", async () => { const client = createClient({ fetch: () => { throw new Error("Response lost"); } }); await assert.rejects(client.api.unlinkDevice({ device_id: "another-device" }), /Network request failed/); diff --git a/src/api/client.ts b/src/api/client.ts index 3f0acad..ecf2772 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -430,6 +430,15 @@ function splitPrimaryFallbackInit(init: RequestInit, headers: Headers): { }; } +// Answers by which the fallback proxy rejects a route before it reaches the +// backend: an unknown route (400/404/405) or the gateway's own 403. +const FALLBACK_ROUTE_REFUSAL_STATUSES = new Set([400, 404, 405]); + +async function isFallbackRouteRefusal(response: Response): Promise { + if (FALLBACK_ROUTE_REFUSAL_STATUSES.has(response.status)) return true; + return response.status === FALLBACK_HTTP_STATUS && (await isProxyGatewayAuthError(response)); +} + async function rejectProxyGatewayAuthError(response: Response): Promise { if (response.status !== FALLBACK_HTTP_STATUS) return response; if (!(await isProxyGatewayAuthError(response))) return response; @@ -477,7 +486,32 @@ async function performFetchRoute( "D", ); const response = await attemptFetch(fallbackUrl, fallbackInit, FALLBACK_TIMEOUT_MS, userSignal); - return await rejectProxyGatewayAuthError(response); + if (userSignal?.aborted || !(await isFallbackRouteRefusal(response))) { + return await rejectProxyGatewayAuthError(response); + } + // The proxy refused the route itself (it can lack newer routes), so the + // write never reached the backend and the primary may still serve it. + // A transport failure above is not retried: the write may have landed. + recordDiagnosticEvent( + "API-Route", + `Fallback route refused write with HTTP ${response.status}; retrying primary; route=${diagnosticRoute}`, + "W", + ); + let primaryResponse: Response; + try { + primaryResponse = await attemptFetch( + primaryUrl, + primaryInit, + BOT_API_FALLBACK_URL ? PRIMARY_TIMEOUT_MS : REQUEST_TIMEOUT_MS, + userSignal, + ); + } catch (primaryError) { + if (response.status === FALLBACK_HTTP_STATUS) throw primaryError; + return response; + } + void response.body?.cancel().catch(() => {}); + primaryUnavailableUntil = 0; + return primaryResponse; } // Endpoints that explicitly require a successful response must not inherit