fix(proxy): make "Test All" read-only + add bulk enable/disable (#6246) (#6299)

Merged into release/v3.8.45. Delta do par #6246 (Test-All read-only + bulk enable/disable), reconciliado com o núcleo #6296 já mergeado. CHANGELOG restaurado (ambos bullets do #6246 coexistem). Reds pré-existentes: dast-smoke (infra) + file-size drift.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-05 17:32:05 -03:00
committed by GitHub
parent f680aacff0
commit b6ffe8ce20
11 changed files with 344 additions and 2 deletions

View File

@@ -1521,6 +1521,11 @@ APP_LOG_TO_FILE=true
# PROXY_AUTO_REMOVE=false
# Consecutive failures before an auto-remove fires. Default: 3.
# PROXY_AUTO_REMOVE_AFTER=3
# Let automated reachability probes (the scheduler + the "Test All" button) WRITE
# a proxy's status. Default "false": probes are read-only and never deactivate a
# proxy — only the operator sets active/inactive (a flaky probe must not strand an
# assigned proxy; #6246). Set "true" to restore the legacy test-and-set behaviour.
# PROXY_HEALTH_AUTO_DEACTIVATE=false
# Allow OAuth and provider validation flows to bypass a pinned proxy and connect
# directly when proxy reachability pre-checks fail. Default: false.

View File

@@ -17,6 +17,8 @@
- **fix(proxy):** stop the v3.8.44 proxy regression that leaked the real IP and disabled healthy proxies ([#6246](https://github.com/diegosouzapw/OmniRoute/issues/6246)). Two coupled defects from the new health scheduler: (1) **IP leak** — when a proxy assigned to a connection was marked `inactive`, resolution fell through to a **direct** egress instead of blocking, exposing the operator's real IP; (2) **over-deactivation** — the sweep flipped a proxy to `inactive` on the **first** failed probe and counted our own 5s timeout / a probe-target `5xx` as the proxy's fault, so healthy paid proxies vanished from egress selection ("my proxies are not being used anymore"). Fix: the sweep decision is extracted into a pure, network-free `decideProxyHealthAction` (`src/lib/proxyHealth/decision.ts`) — by default the health check now **only counts/logs and never downgrades status** (a proxy is downgraded/removed only with `PROXY_AUTO_REMOVE=true`, after `PROXY_AUTO_REMOVE_AFTER` **consecutive** conclusive failures); probes are classified tri-state so an inconclusive result (our timeout, or a `5xx` from the probe target) never penalizes the proxy, and the probe timeout is raised 5s→15s. Separately, `safeResolveProxy` now **fails closed** via the existing policy: a connection whose assigned proxy is dead is blocked instead of leaking direct (`hasBlockingProxyAssignment`), honoring the explicit `proxy off` toggles and the `PROXY_FAIL_OPEN=true` opt-out. Existing proxies stuck `inactive` by the old behavior need a one-time manual re-activate (the operator owns proxy status). Regression guards: `tests/unit/proxy-health-decide-action-6246.test.ts`, `tests/unit/proxy-assigned-unavailable-6246.test.ts`.
- **fix(proxy):** make "Test All" read-only and add bulk enable/disable ([#6246](https://github.com/diegosouzapw/OmniRoute/issues/6246)). Complements the core fail-closed / scheduler fix (#6296) with the two remaining reporter asks. (1) The **"Test All" button** (`POST /api/settings/proxies/auto-test`) used to flip a proxy to `inactive` on a failed reachability probe; since the egress selector excludes `inactive` proxies, a flaky probe (an unreachable `httpbin.org`, a proxy that blocks `HEAD`, or a slow paid proxy) silently disabled every proxy that failed — "Test All" is now **read-only by default** (only the operator sets a proxy active/inactive; opt back into the legacy test-and-set with `PROXY_HEALTH_AUTO_DEACTIVATE=true`). (2) Adds a **bulk enable/disable** proxies endpoint + toolbar action (`POST /api/settings/proxies/batch-activate`) so an operator can re-activate proxies in one click. Regression guard: `tests/unit/proxy-health-6246.test.ts`. (thanks @tenshiak)
- **chatcore (tools): stop the default 128-tool cap from silently dropping opencode's `task`/MCP tools.** opencode (used as an MCP/agent host) sends a large tool list; when it exceeds the speculative `MAX_TOOLS_LIMIT` (128) default, `truncateToolList` did a blind `tools.slice(0, 128)`, dropping every tool past index 128 — including opencode's built-in `task` tool (subagent launch) and many MCP tools, so models routed through OmniRoute could no longer spawn subagents or reach part of their tools. The cap exists to avoid upstream `400`s for providers with real hard limits (e.g. grok-cli 200), so it is kept for those: detection of the opencode client (`isOpencodeClient` — any `x-opencode-*` header, or `opencode` in the user-agent) now only bypasses the **speculative 128 default**, never a known provider ceiling. Precedence is explicit — a proactive/detected provider limit always truncates (even for opencode); otherwise opencode forwards its full tool list; otherwise the unchanged 128 default applies to every other client. Refactors `getEffectiveToolLimit` into `getKnownToolLimit(provider) ?? DEFAULT_LIMIT` (byte-identical for existing callers) and fixes a cosmetic debug-log that reported the truncated count instead of the original. Regression guard: `tests/unit/tool-limit-detector.test.ts`.
- **fix(mitm):** the macOS MITM-cert install check now matches the system keychain again. `security find-certificate -a -Z` prints the SHA-1 as a colon-less hex string, but the installed-check compared it against `getCertFingerprint()`'s colon-separated form, so the substring match never hit — the cert was reported as not-installed and re-prompted for the sudo install on every run. Fingerprints are now normalized (colons stripped, upper-cased) on both sides via the extracted `macCertOutputHasFingerprint` helper. Regression guard: `tests/unit/mitm-cert-mac-fingerprint.test.ts`. ([#6204](https://github.com/diegosouzapw/OmniRoute/pull/6204), closes [#6134](https://github.com/diegosouzapw/OmniRoute/issues/6134) — thanks @rianonehub)

View File

@@ -836,6 +836,7 @@ Anthropic-compatible provider instead.
| `PROXY_HEALTH_ENABLED` | `true` | `src/lib/proxyHealth/scheduler.ts` | Set `false` to disable the background proxy health scheduler that periodically probes registered proxies. |
| `PROXY_HEALTH_INTERVAL_MS` | `600000` | `src/lib/proxyHealth/scheduler.ts` | Background health-scheduler sweep interval in ms (minimum `60000`). |
| `PROXY_HEALTH_TEST_URL` | `https://httpbin.org/ip` | `src/lib/proxyHealth/scheduler.ts` | Reachability probe target used by the scheduler and the `/api/settings/proxies/auto-test` endpoint. Point it at an internal/self-hosted URL to avoid the public default. |
| `PROXY_HEALTH_AUTO_DEACTIVATE` | `false` | `src/lib/proxyHealth/statusPolicy.ts` | When `false` (default), automated reachability probes (the scheduler + the `/api/settings/proxies/auto-test` "Test All" button) are **read-only** and never write a proxy's status — only the operator sets active/inactive, so a flaky probe can't strand an assigned proxy (#6246). Set `true` to restore the legacy test-and-set behaviour. |
| `PROXY_AUTO_REMOVE` | `false` | `src/lib/proxyHealth/scheduler.ts` | Set `true` to let the scheduler auto-remove proxies after repeated consecutive failures. |
| `PROXY_AUTO_REMOVE_AFTER` | `3` | `src/lib/proxyHealth/scheduler.ts` | Consecutive failures before the scheduler auto-removes a proxy (when `PROXY_AUTO_REMOVE=true`). |
| `OMNIROUTE_CONTROL_PLANE_PROXY_DIRECT_FALLBACK` | `false` | `src/shared/constants/featureFlagDefinitions.ts` | Allow OAuth and provider validation flows to bypass a pinned proxy and connect directly when proxy reachability pre-checks fail. Effective precedence is Feature Flags DB override > env var > default. |

View File

@@ -7,7 +7,9 @@ interface ProxyBatchActionsProps {
selectedCount: number;
batchDeleting: boolean;
autoTesting: boolean;
batchActivating: boolean;
onBatchDelete: () => void;
onBatchActivate: () => void;
onAutoTestAll: () => void;
}
@@ -15,7 +17,9 @@ export function ProxyBatchActions({
selectedCount,
batchDeleting,
autoTesting,
batchActivating,
onBatchDelete,
onBatchActivate,
onAutoTestAll,
}: ProxyBatchActionsProps) {
const t = useTranslations("proxyRegistry");
@@ -27,6 +31,16 @@ export function ProxyBatchActions({
<span className="text-xs text-text-muted">
{t("batchSelectedCount", { count: selectedCount })}
</span>
<Button
size="sm"
variant="secondary"
icon="check_circle"
onClick={onBatchActivate}
loading={batchActivating}
data-testid="proxy-registry-batch-activate"
>
{t("batchActivateSelected", { count: selectedCount })}
</Button>
<Button
size="sm"
variant="secondary"

View File

@@ -267,9 +267,11 @@ export default function ProxyRegistryManager() {
setSelectedIds,
batchDeleting,
autoTesting,
batchActivating,
toggleSelectAll: hookToggleSelectAll,
toggleSelect,
handleBatchDelete: hookHandleBatchDelete,
handleBatchActivate: hookHandleBatchActivate,
handleAutoTestAll: hookHandleAutoTestAll,
} = useProxyBatchOperations(load);
@@ -279,6 +281,10 @@ export default function ProxyRegistryManager() {
hookHandleBatchDelete(setError);
}, [hookHandleBatchDelete, setError]);
const handleBatchActivate = useCallback(() => {
hookHandleBatchActivate(setError, "active");
}, [hookHandleBatchActivate, setError]);
const handleAutoTestAll = useCallback(() => {
hookHandleAutoTestAll(setError, setTestById);
}, [hookHandleAutoTestAll, setError, setTestById]);
@@ -636,7 +642,9 @@ export default function ProxyRegistryManager() {
selectedCount={selectedIds.size}
batchDeleting={batchDeleting}
autoTesting={autoTesting}
batchActivating={batchActivating}
onBatchDelete={handleBatchDelete}
onBatchActivate={handleBatchActivate}
onAutoTestAll={handleAutoTestAll}
/>
<Button

View File

@@ -19,6 +19,7 @@ export function useProxyBatchOperations(load: () => Promise<void>) {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [batchDeleting, setBatchDeleting] = useState(false);
const [autoTesting, setAutoTesting] = useState(false);
const [batchActivating, setBatchActivating] = useState(false);
const toggleSelectAll = useCallback(
(allSelected: boolean, items: Array<{ id: string }>) => {
@@ -63,6 +64,34 @@ export function useProxyBatchOperations(load: () => Promise<void>) {
[selectedIds, load]
);
// #6246: bulk enable/disable — the only automated path that writes proxy
// status (an explicit operator action). Health probes are read-only by default.
const handleBatchActivate = useCallback(
async (setError: (msg: string | null) => void, status: "active" | "inactive" = "active") => {
if (selectedIds.size === 0) return;
setBatchActivating(true);
try {
const res = await fetch("/api/settings/proxies/batch-activate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids: Array.from(selectedIds), status }),
});
const data: { error?: { message?: string } } = await res.json().catch(() => ({}));
if (res.ok) {
setSelectedIds(new Set());
await load();
} else {
setError(data?.error?.message || "Batch update failed");
}
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Batch update failed");
} finally {
setBatchActivating(false);
}
},
[selectedIds, load]
);
const handleAutoTestAll = useCallback(
async (
setError: (msg: string | null) => void,
@@ -106,9 +135,11 @@ export function useProxyBatchOperations(load: () => Promise<void>) {
setSelectedIds,
batchDeleting,
autoTesting,
batchActivating,
toggleSelectAll,
toggleSelect,
handleBatchDelete,
handleBatchActivate,
handleAutoTestAll,
};
}

View File

@@ -4,6 +4,7 @@ import { createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { createProxyDispatcher } from "@omniroute/open-sse/utils/proxyDispatcher";
import { fetch as undiciFetch } from "undici";
import { resolveHealthCheckStatusWrite } from "@/lib/proxyHealth/statusPolicy";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { createErrorResponse } from "@/lib/api/errorResponse";
@@ -43,11 +44,16 @@ async function testSingleProxy(proxy: { id: string; type: string; host: string;
});
const latencyMs = Date.now() - start;
const alive = resp.status < 500;
await updateProxy(proxy.id, { status: alive ? "active" : "inactive" }).catch(() => {});
// #6246: "Test All" is a test, not test-and-set. By default an automated probe
// never mutates a proxy's status (only the operator does). Opt back into the
// legacy write with PROXY_HEALTH_AUTO_DEACTIVATE=true.
const statusWrite = resolveHealthCheckStatusWrite(alive);
if (statusWrite) await updateProxy(proxy.id, { status: statusWrite }).catch(() => {});
return { proxyId: proxy.id, host: proxy.host, port: proxy.port, alive, latencyMs };
} catch (err) {
const latencyMs = Date.now() - start;
await updateProxy(proxy.id, { status: "inactive" }).catch(() => {});
const statusWrite = resolveHealthCheckStatusWrite(false);
if (statusWrite) await updateProxy(proxy.id, { status: statusWrite }).catch(() => {});
return {
proxyId: proxy.id,
host: proxy.host,

View File

@@ -0,0 +1,72 @@
import { z } from "zod";
import { updateProxy } from "@/lib/localDb";
import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { clearDispatcherCache } from "@omniroute/open-sse/utils/proxyDispatcher";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
const batchActivateSchema = z.object({
ids: z.array(z.string()).min(1).max(500),
// "active" (bulk enable) or "inactive" (bulk disable). Defaults to enable —
// the #6246 ask was a way to re-enable proxies an operator had disabled.
status: z.enum(["active", "inactive"]).optional().default("active"),
});
/**
* POST /api/settings/proxies/batch-activate
*
* Bulk-set the status of multiple proxies in one request (#6246). This is the
* ONLY automated path allowed to change proxy status — it is an explicit
* operator action, unlike the reachability probes which are read-only by
* default (see src/lib/proxyHealth/statusPolicy.ts).
*/
export async function POST(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
let rawBody: unknown;
try {
rawBody = await request.json();
} catch {
return createErrorResponse({ status: 400, message: "Invalid JSON body", type: "invalid_request" });
}
const validation = validateBody(batchActivateSchema, rawBody);
if (isValidationFailure(validation)) {
return createErrorResponse({ status: 400, message: validation.error.message, type: "invalid_request" });
}
const { ids, status } = validation.data;
try {
const results: Array<{ id: string; success: boolean; error?: string }> = [];
let updatedCount = 0;
for (const id of ids) {
try {
if (await updateProxy(id, { status })) {
results.push({ id, success: true });
updatedCount++;
} else {
results.push({ id, success: false, error: "Proxy not found" });
}
} catch (err) {
results.push({ id, success: false, error: err instanceof Error ? err.message : "Unknown error" });
}
}
if (updatedCount > 0) {
try { clearDispatcherCache(); } catch { /* non-critical */ }
}
return Response.json({
success: updatedCount > 0,
status,
updated: updatedCount,
failed: ids.length - updatedCount,
results,
});
} catch (error) {
return createErrorResponseFromUnknown(error, "Failed to batch update proxy status");
}
}

View File

@@ -7940,6 +7940,7 @@
"errorTestFailed": "Failed to test proxies",
"batchSelectedCount": "{count} selected",
"batchDeleteSelected": "Delete {count} selected",
"batchActivateSelected": "Enable {count} selected",
"testPassed": "✓ OK"
},
"playground": {

View File

@@ -0,0 +1,41 @@
/**
* Proxy health-check status-write policy (#6246).
*
* The manual "Test All" reachability probe (`/api/settings/proxies/auto-test`)
* used to flip a proxy's `status` to `inactive` on a single failed probe. Because
* the egress selector excludes `inactive` proxies (PROXY_ALIVE_PREDICATE), a flaky
* external probe (an unreachable httpbin.org, a proxy that blocks HEAD, or a slow
* paid proxy) silently disabled every proxy that failed — "Test All" is a test,
* not test-and-set.
*
* Policy: automated reachability probes are **read-only by default** — they never
* write a proxy's status. Only the operator sets a proxy active/inactive (same
* contract as provider accounts, which are auto-disabled only when the operator
* opts in). Set `PROXY_HEALTH_AUTO_DEACTIVATE=true` to restore the legacy
* test-and-set behavior (probe result writes `active`/`inactive`).
*/
/**
* Resolve the status an automated health probe should WRITE for a proxy, or
* `null` to leave the status untouched (the default). Pure + unit-testable.
*
* @param alive whether the reachability probe succeeded
* @param env environment source (defaults to process.env)
*/
export function resolveHealthCheckStatusWrite(
alive: boolean,
env: { PROXY_HEALTH_AUTO_DEACTIVATE?: string } = process.env
): "active" | "inactive" | null {
if ((env.PROXY_HEALTH_AUTO_DEACTIVATE ?? "").trim().toLowerCase() !== "true") {
// Default: never let an automated probe change a proxy's status.
return null;
}
return alive ? "active" : "inactive";
}
/** True when automated probes are allowed to write proxy status (opt-in). */
export function isProxyHealthAutoDeactivateEnabled(
env: { PROXY_HEALTH_AUTO_DEACTIVATE?: string } = process.env
): boolean {
return (env.PROXY_HEALTH_AUTO_DEACTIVATE ?? "").trim().toLowerCase() === "true";
}

View File

@@ -0,0 +1,161 @@
/**
* #6246 — "Massive issue with proxies since 3.8.44 - IP leak" (delta scope).
*
* The core fail-closed + scheduler regression is fixed in PR #6296. This suite
* covers the two remaining reporter asks that #6296 does NOT touch:
* (#3) the "Test All" button (`/api/settings/proxies/auto-test`) must be a test,
* not test-and-set — it flipped a proxy to `inactive` on a failed probe,
* and the egress selector excludes `inactive` proxies, so "Test All"
* silently disabled every proxy that failed a flaky probe;
* (#4) a bulk enable/disable endpoint so an operator can re-activate proxies
* in one action.
*
* DB-backed and network-free: the probe targets a dead localhost proxy
* (immediate ECONNREFUSED — no outbound traffic).
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-proxy-6246-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = "test-secret";
delete process.env.INITIAL_PASSWORD; // auth not required in this test env
delete process.env.PROXY_HEALTH_AUTO_DEACTIVATE;
const core = await import("../../src/lib/db/core.ts");
const proxiesDb = await import("../../src/lib/db/proxies.ts");
const { resolveHealthCheckStatusWrite, isProxyHealthAutoDeactivateEnabled } = await import(
"../../src/lib/proxyHealth/statusPolicy.ts"
);
const { POST: autoTestPost } = await import(
"../../src/app/api/settings/proxies/auto-test/route.ts"
);
const { POST: batchActivatePost } = await import(
"../../src/app/api/settings/proxies/batch-activate/route.ts"
);
function resetStorage() {
delete process.env.INITIAL_PASSWORD;
delete process.env.PROXY_HEALTH_AUTO_DEACTIVATE;
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
// ── status-write policy ───────────────────────────────────────────────────────
test("policy: automated probes never write status by default", () => {
assert.equal(resolveHealthCheckStatusWrite(false, {}), null);
assert.equal(resolveHealthCheckStatusWrite(true, {}), null);
assert.equal(isProxyHealthAutoDeactivateEnabled({}), false);
});
test("policy: PROXY_HEALTH_AUTO_DEACTIVATE=true restores legacy test-and-set", () => {
const env = { PROXY_HEALTH_AUTO_DEACTIVATE: "true" };
assert.equal(resolveHealthCheckStatusWrite(false, env), "inactive");
assert.equal(resolveHealthCheckStatusWrite(true, env), "active");
assert.equal(isProxyHealthAutoDeactivateEnabled(env), true);
});
// ── (#3) "Test All" must not deactivate proxies by default ─────────────────────
test("auto-test does NOT deactivate a failing proxy by default", async () => {
resetStorage();
// Dead proxy: nothing listens on this port → the probe fails immediately.
const p = await proxiesDb.createProxy({
name: "dead",
type: "http",
host: "127.0.0.1",
port: 1,
});
assert.ok(p?.id);
assert.equal(p.status, "active");
const req = new Request("http://localhost/api/settings/proxies/auto-test", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ids: [p.id] }),
});
const res = await autoTestPost(req);
assert.equal(res.status, 200);
const body = await res.json();
// The probe reports it dead...
assert.equal(body.results?.[0]?.alive, false);
// ...but the stored status is untouched (the regression flipped it to inactive).
const after = await proxiesDb.getProxyById(p.id, { includeSecrets: false });
assert.equal(after?.status, "active", "auto-test must not mutate proxy status by default");
});
test("auto-test still deactivates when PROXY_HEALTH_AUTO_DEACTIVATE=true (opt-in)", async () => {
resetStorage();
process.env.PROXY_HEALTH_AUTO_DEACTIVATE = "true";
const p = await proxiesDb.createProxy({
name: "dead",
type: "http",
host: "127.0.0.1",
port: 1,
});
assert.ok(p?.id);
const req = new Request("http://localhost/api/settings/proxies/auto-test", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ids: [p.id] }),
});
const res = await autoTestPost(req);
assert.equal(res.status, 200);
const after = await proxiesDb.getProxyById(p.id, { includeSecrets: false });
assert.equal(after?.status, "inactive");
delete process.env.PROXY_HEALTH_AUTO_DEACTIVATE;
});
// ── (#4) bulk enable/disable proxies ──────────────────────────────────────────
test("batch-activate bulk-enables multiple proxies (default status=active)", async () => {
resetStorage();
const a = await proxiesDb.createProxy({ name: "a", type: "http", host: "127.0.0.1", port: 8080 });
const b = await proxiesDb.createProxy({ name: "b", type: "http", host: "127.0.0.1", port: 8081 });
await proxiesDb.updateProxy(a!.id, { status: "inactive" });
await proxiesDb.updateProxy(b!.id, { status: "inactive" });
const req = new Request("http://localhost/api/settings/proxies/batch-activate", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ids: [a!.id, b!.id] }),
});
const res = await batchActivatePost(req);
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.updated, 2);
assert.equal(body.status, "active");
assert.equal((await proxiesDb.getProxyById(a!.id, { includeSecrets: false }))?.status, "active");
assert.equal((await proxiesDb.getProxyById(b!.id, { includeSecrets: false }))?.status, "active");
});
test("batch-activate can bulk-disable with status=inactive", async () => {
resetStorage();
const a = await proxiesDb.createProxy({ name: "a", type: "http", host: "127.0.0.1", port: 8080 });
const req = new Request("http://localhost/api/settings/proxies/batch-activate", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ids: [a!.id], status: "inactive" }),
});
const res = await batchActivatePost(req);
assert.equal(res.status, 200);
assert.equal((await proxiesDb.getProxyById(a!.id, { includeSecrets: false }))?.status, "inactive");
});
test("batch-activate rejects an empty ids array with 400", async () => {
resetStorage();
const req = new Request("http://localhost/api/settings/proxies/batch-activate", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ids: [] }),
});
const res = await batchActivatePost(req);
assert.equal(res.status, 400);
});