Files
OmniRoute/tests/unit/ui/ProxyRegistryManager-credential-autofill.test.tsx
Gi99lin b1a2ff6887 feat(proxy): non-destructive auto-disable mode for the proxy health scheduler (#10342)
* 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>
2026-08-17 08:02:16 -03:00

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();
});
});