mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-19 13:23:50 +03:00
* feat(proxy): add non-destructive auto-disable mode for the proxy health scheduler PROXY_AUTO_REMOVE was the only opt-in action the background proxy health scheduler could take on a consistently failing proxy, and it deletes the row. For a manually-maintained proxy chain (multi-proxy pool/rotation, #6365) that is too destructive just to exclude a temporarily-dead member. Add PROXY_AUTO_DISABLE as a sibling flag: at the same consecutive-failure threshold it soft-disables the proxy (status "dead") instead of removing it. "dead" is already one of the statuses the pool/rotation alive-filter excludes, so a disabled proxy drops out of the active chain immediately with no other code changes. The scheduler keeps probing dead proxies on its normal interval, and the existing recovery branch (previously autoRemove-only) re-activates it automatically once it starts answering again. decision.ts's decideProxyHealthAction() gets an optional `autoDisable` input (defaults to false, so existing callers are unaffected) and a "dead" status value; scheduler.ts wires the new PROXY_AUTO_DISABLE env flag through. If both flags are set, auto-remove wins. getProxyHealthStats() now also surfaces the registry `status` so operators can see when a proxy was auto-disabled, and ProxyStatusBadge now treats the full "not alive" status set (not just the literal string "inactive") as inactive in the dashboard. * test(proxy): assert registry status in getProxyHealthStats output The non-destructive auto-disable change added the live registry status to the stats object returned by getProxyHealthStats. Align the pre-existing db-proxies-crud assertion with the intended output shape. Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com> * fix(proxy): preserve auto-disabled status in dashboard edits Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com> Co-authored-by: Gi99lin <Gi99lin@users.noreply.github.com> Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com> Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
239 lines
8.1 KiB
TypeScript
239 lines
8.1 KiB
TypeScript
// @vitest-environment jsdom
|
|
import React, { act } from "react";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const translate = (key: string) => key;
|
|
|
|
vi.mock("next-intl", () => ({
|
|
useTranslations: () => translate,
|
|
}));
|
|
|
|
const SEEDED_PROXY = {
|
|
id: "proxy-8855",
|
|
name: "Seeded proxy",
|
|
type: "http",
|
|
host: "127.0.0.1",
|
|
port: 8080,
|
|
username: "stored-user",
|
|
password: "stored-password",
|
|
status: "active",
|
|
family: "auto",
|
|
};
|
|
|
|
const DEAD_PROXY = {
|
|
...SEEDED_PROXY,
|
|
id: "proxy-dead-10342",
|
|
name: "Auto-disabled proxy",
|
|
status: "dead",
|
|
};
|
|
|
|
let root: Root;
|
|
let container: HTMLDivElement;
|
|
let postBody: Record<string, unknown> | undefined;
|
|
let patchBody: Record<string, unknown> | undefined;
|
|
let responseItems: Array<typeof SEEDED_PROXY>;
|
|
|
|
function jsonResponse(body: unknown): Response {
|
|
return { ok: true, json: async () => body } as Response;
|
|
}
|
|
|
|
function findButton(text: string): HTMLButtonElement {
|
|
const button = Array.from(container.querySelectorAll("button")).find((candidate) =>
|
|
candidate.textContent?.includes(text)
|
|
);
|
|
if (!button) throw new Error(`Button not found: ${text}`);
|
|
return button;
|
|
}
|
|
|
|
function findCredentialInput(label: string): HTMLInputElement {
|
|
const labelNode = Array.from(container.querySelectorAll("label")).find(
|
|
(candidate) => candidate.textContent?.trim() === label
|
|
);
|
|
const input = labelNode?.parentElement?.querySelector<HTMLInputElement>("input");
|
|
if (!input) throw new Error(`Credential input not found: ${label}`);
|
|
return input;
|
|
}
|
|
|
|
function setInputValue(input: HTMLInputElement, value: string) {
|
|
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
|
|
if (!setter) throw new Error("HTMLInputElement value setter is unavailable");
|
|
act(() => {
|
|
setter.call(input, value);
|
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
});
|
|
}
|
|
|
|
async function click(element: HTMLElement) {
|
|
await act(async () => {
|
|
element.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
}
|
|
|
|
async function waitFor(assertion: () => void, timeoutMs = 2000) {
|
|
const startedAt = Date.now();
|
|
let lastError: unknown;
|
|
while (Date.now() - startedAt <= timeoutMs) {
|
|
try {
|
|
assertion();
|
|
return;
|
|
} catch (error) {
|
|
lastError = error;
|
|
await act(async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
});
|
|
}
|
|
}
|
|
throw lastError;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
(
|
|
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
|
).IS_REACT_ACT_ENVIRONMENT = true;
|
|
postBody = undefined;
|
|
patchBody = undefined;
|
|
responseItems = [SEEDED_PROXY];
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const url = String(input);
|
|
if (url === "/api/settings/proxies" && init?.method === "POST") {
|
|
postBody = JSON.parse(String(init.body));
|
|
return jsonResponse({ item: { ...SEEDED_PROXY, ...postBody } });
|
|
}
|
|
if (url === "/api/settings/proxies" && init?.method === "PATCH") {
|
|
patchBody = JSON.parse(String(init.body));
|
|
return jsonResponse({ item: { ...responseItems[0], ...patchBody } });
|
|
}
|
|
if (url === "/api/settings/proxies") {
|
|
return jsonResponse({ items: responseItems });
|
|
}
|
|
if (url.startsWith("/api/settings/proxies/pool?")) {
|
|
return jsonResponse({ members: [], strategy: "round-robin" });
|
|
}
|
|
if (url.startsWith("/api/settings/proxies/health")) {
|
|
return jsonResponse({ items: [] });
|
|
}
|
|
if (url.startsWith("/api/settings/proxies/assignments")) {
|
|
return jsonResponse({ items: [] });
|
|
}
|
|
throw new Error(`Unexpected fetch: ${url}`);
|
|
})
|
|
);
|
|
});
|
|
|
|
afterEach(() => {
|
|
act(() => root.unmount());
|
|
container.remove();
|
|
vi.unstubAllGlobals();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe("ProxyRegistryManager credential autofill regression #8855", () => {
|
|
it("keeps Edit → close → Add credentials blank and isolates both fields from autofill", { timeout: 60000 }, async () => {
|
|
const { default: ProxyRegistryManager } =
|
|
await import("@/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager");
|
|
|
|
await act(async () => {
|
|
root.render(<ProxyRegistryManager />);
|
|
});
|
|
await waitFor(() => expect(container.textContent).toContain(SEEDED_PROXY.name));
|
|
|
|
await click(findButton("edit"));
|
|
const editUsername = findCredentialInput("labelUsername");
|
|
const editPassword = findCredentialInput("labelPassword");
|
|
expect(editUsername.value).toBe("");
|
|
expect(editPassword.value).toBe("");
|
|
|
|
setInputValue(editUsername, "edit-user-sentinel");
|
|
setInputValue(editPassword, "edit-password-sentinel");
|
|
await click(container.querySelector<HTMLButtonElement>('button[aria-label="close"]')!);
|
|
await click(
|
|
container.querySelector<HTMLButtonElement>('[data-testid="proxy-registry-open-create"]')!
|
|
);
|
|
|
|
const createUsername = findCredentialInput("labelUsername");
|
|
const createPassword = findCredentialInput("labelPassword");
|
|
expect(createUsername.value).toBe("");
|
|
expect(createPassword.value).toBe("");
|
|
|
|
expect.soft(createUsername.getAttribute("autocomplete")).toBe("off");
|
|
expect.soft(createPassword.getAttribute("autocomplete")).toBe("new-password");
|
|
for (const input of [createUsername, createPassword]) {
|
|
expect.soft(input.getAttribute("data-1p-ignore")).toBe("true");
|
|
expect.soft(input.getAttribute("data-lpignore")).toBe("true");
|
|
}
|
|
|
|
setInputValue(
|
|
container.querySelector<HTMLInputElement>('[data-testid="proxy-registry-name-input"]')!,
|
|
"New proxy"
|
|
);
|
|
setInputValue(
|
|
container.querySelector<HTMLInputElement>('[data-testid="proxy-registry-host-input"]')!,
|
|
"proxy.example.test"
|
|
);
|
|
await click(findButton("save"));
|
|
await waitFor(() => expect(postBody).toBeDefined());
|
|
|
|
expect([undefined, ""]).toContain(postBody?.username);
|
|
expect([undefined, ""]).toContain(postBody?.password);
|
|
expect(postBody?.username).not.toBe("edit-user-sentinel");
|
|
expect(postBody?.password).not.toBe("edit-password-sentinel");
|
|
});
|
|
|
|
it("round-trips dead status through Edit and excludes it from pool candidates", async () => {
|
|
responseItems = [DEAD_PROXY];
|
|
|
|
const { default: ProxyRegistryManager } =
|
|
await import("@/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager");
|
|
|
|
await act(async () => {
|
|
root.render(<ProxyRegistryManager />);
|
|
});
|
|
await waitFor(() => expect(container.textContent).toContain(DEAD_PROXY.name));
|
|
|
|
await click(findButton("edit"));
|
|
const statusSelect = container.querySelector<HTMLSelectElement>(
|
|
'[data-testid="proxy-registry-status-select"]'
|
|
);
|
|
expect(statusSelect).not.toBeNull();
|
|
expect(statusSelect?.value).toBe("dead");
|
|
expect(statusSelect?.querySelector('option[value="dead"]')).not.toBeNull();
|
|
|
|
await click(findButton("save"));
|
|
await waitFor(() => expect(patchBody).toBeDefined());
|
|
expect(patchBody).toMatchObject({ id: DEAD_PROXY.id, status: "dead" });
|
|
|
|
await click(findButton("managePool"));
|
|
const scopeSelect = container.querySelector<HTMLSelectElement>(
|
|
'[data-testid="proxy-registry-pool-scope"]'
|
|
);
|
|
expect(scopeSelect).not.toBeNull();
|
|
const setter = Object.getOwnPropertyDescriptor(
|
|
window.HTMLSelectElement.prototype,
|
|
"value"
|
|
)?.set;
|
|
if (!setter || !scopeSelect) throw new Error("Pool scope select is unavailable");
|
|
act(() => {
|
|
setter.call(scopeSelect, "global");
|
|
scopeSelect.dispatchEvent(new Event("change", { bubbles: true }));
|
|
});
|
|
await click(container.querySelector<HTMLButtonElement>(
|
|
'[data-testid="proxy-registry-pool-load"]'
|
|
)!);
|
|
await waitFor(() =>
|
|
expect(container.querySelector('[data-testid="proxy-registry-pool-add-select"]')).not.toBeNull()
|
|
);
|
|
|
|
const poolAddSelect = container.querySelector<HTMLSelectElement>(
|
|
'[data-testid="proxy-registry-pool-add-select"]'
|
|
);
|
|
expect(poolAddSelect?.querySelector(`option[value="${DEAD_PROXY.id}"]`)).toBeNull();
|
|
});
|
|
});
|