mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-16 03:42:21 +03:00
fix(proxies): keep the stored status when a write does not send one (#13577)
A write that does not send a proxy `status` (subscription refresh, bulk import, PATCH) no longer turns a disabled or dead proxy back on; the stored status is kept. Real DB-backed tests through `upsertProxy`, the subscription sync against a local feed and `handleProxyUpdate`.
Validated first on the combined board of all 38 PRs of this batch (10 merged as-is, 28 after the maintainer rework) on top of release/v3.8.51 c0f92ec: typecheck:core, check:open-sse-typecheck and check:dashboard-typecheck clean; ESLint clean on every changed file; file-size (rebaselined for the combined growth), complexity, cognitive-complexity, changelog-integrity, docs-counts, docs-sync, migration-numbering and i18n new-key gates green; 735 focused node:test cases with the only batch-caused failure (a flag-count assertion) fixed. Then re-validated alone on the fresh release tip right before this merge: ESLint on the changed files, typecheck:core, check:open-sse-typecheck, the file-size/complexity/changelog gates and this PR's own tests.
Thanks @maxmad64bis!
This commit is contained in:
@@ -0,0 +1 @@
|
||||
- **fix(proxies):** a subscription refresh, a bulk re-import or an API update that omits the status no longer turns a disabled proxy back on, and a refresh no longer rewrites a manual proxy that shares a subscription node's address ([#13577](https://github.com/diegosouzapw/OmniRoute/pull/13577)) — thanks @maxmad64bis
|
||||
@@ -581,7 +581,7 @@ import {
|
||||
password: entry.password || undefined,
|
||||
region: entry.region || null,
|
||||
notes: entry.notes || null,
|
||||
status: entry.status as "active" | "inactive",
|
||||
status: entry.status as "active" | "inactive" | undefined,
|
||||
})),
|
||||
};
|
||||
|
||||
@@ -1413,13 +1413,13 @@ import {
|
||||
<td className="py-1 px-2 font-mono text-text-muted">{entry.port}</td>
|
||||
<td className="py-1 px-2 text-text-muted">{entry.username || "—"}</td>
|
||||
<td className="py-1 px-2 text-text-muted">{entry.region || "—"}</td>
|
||||
<td className="py-1 px-2">
|
||||
<td className="py-1 px-2 text-text-muted">
|
||||
<span
|
||||
className={
|
||||
entry.status === "active" ? "text-emerald-400" : "text-text-muted"
|
||||
}
|
||||
className={entry.status === "active" ? "text-emerald-400" : undefined}
|
||||
>
|
||||
{entry.status === "active" ? t("statusActive") : t("statusInactive")}
|
||||
{entry.status === "active" && t("statusActive")}
|
||||
{entry.status === "inactive" && t("statusInactive")}
|
||||
{!entry.status && "—"}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -28,7 +28,8 @@ export type ParsedProxyEntry = {
|
||||
password: string;
|
||||
type: string;
|
||||
region: string;
|
||||
status: string;
|
||||
/** Absent when the line carries no status: the import then leaves the stored one alone. */
|
||||
status?: string;
|
||||
notes: string;
|
||||
};
|
||||
|
||||
@@ -90,7 +91,6 @@ function pushShorthandEntry(
|
||||
password,
|
||||
type: normalizedType,
|
||||
region: "",
|
||||
status: "active",
|
||||
notes: "",
|
||||
});
|
||||
return true;
|
||||
@@ -236,8 +236,8 @@ export function parseBulkImportText(text: string): {
|
||||
errors.push({ line: lineNum, reason: "bulkImportErrorInvalidType" });
|
||||
continue;
|
||||
}
|
||||
const normalizedStatus = (status || "active").toLowerCase();
|
||||
if (!VALID_PROXY_STATUSES[normalizedStatus]) {
|
||||
const normalizedStatus = status ? status.toLowerCase() : undefined;
|
||||
if (normalizedStatus !== undefined && !VALID_PROXY_STATUSES[normalizedStatus]) {
|
||||
errors.push({ line: lineNum, reason: "bulkImportErrorInvalidStatus" });
|
||||
continue;
|
||||
}
|
||||
@@ -250,7 +250,7 @@ export function parseBulkImportText(text: string): {
|
||||
password: password || "",
|
||||
type: normalizedType,
|
||||
region: region || "",
|
||||
status: normalizedStatus,
|
||||
...(normalizedStatus ? { status: normalizedStatus } : {}),
|
||||
notes: notes || "",
|
||||
});
|
||||
continue;
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
} from "./proxies/mappers";
|
||||
import { isGlobalProxyEnabled, PROXY_ALIVE_PREDICATE } from "./proxies/guards";
|
||||
import { bumpProxyRegistryGeneration } from "./proxies/registryGeneration";
|
||||
import { isProxyRegistryStatus } from "@/shared/constants/proxyRegistryStatus";
|
||||
export {
|
||||
hasBlockingProxyAssignment,
|
||||
hasBlockingProxyAssignmentForProvider,
|
||||
@@ -334,22 +335,48 @@ export async function createProxy(payload: ProxyPayload) {
|
||||
*
|
||||
* #7703: password is mutable and must not be part of the identity key. Including
|
||||
* it caused password-only credential rotations to create duplicate entries.
|
||||
*
|
||||
* On an existing row the status is written only when the payload carries a valid one,
|
||||
* so a write that omits it never revives a proxy the operator or auto-disable turned off.
|
||||
*
|
||||
* `claimOwnership: false` is reserved for subscription sync: a matched row whose
|
||||
* subscription_id differs from the payload's (manual rows included) is not written
|
||||
* at all and the call returns `action: "skipped"`. The sync always sends its subscriptionId:
|
||||
* a caller that omits it would find manual rows (null === null) counted as owned.
|
||||
*/
|
||||
export async function upsertProxy(payload: ProxyPayload): Promise<{
|
||||
proxy: ProxyRegistryRecord | null;
|
||||
action: "created" | "updated";
|
||||
}> {
|
||||
export async function upsertProxy(
|
||||
payload: ProxyPayload
|
||||
): Promise<{ proxy: ProxyRegistryRecord | null; action: "created" | "updated" }>;
|
||||
export async function upsertProxy(
|
||||
payload: ProxyPayload,
|
||||
options: { claimOwnership?: boolean }
|
||||
): Promise<{ proxy: ProxyRegistryRecord | null; action: "created" | "updated" | "skipped" }>;
|
||||
export async function upsertProxy(
|
||||
payload: ProxyPayload,
|
||||
options: { claimOwnership?: boolean } = {}
|
||||
): Promise<{ proxy: ProxyRegistryRecord | null; action: "created" | "updated" | "skipped" }> {
|
||||
const db = getDbInstance();
|
||||
const host = (payload.host || "").trim();
|
||||
const port = Number(payload.port);
|
||||
const username = (payload.username || "").trim();
|
||||
|
||||
const existing = db
|
||||
.prepare("SELECT id FROM proxy_registry WHERE host = ? AND port = ? AND username = ? LIMIT 1")
|
||||
.get(host, port, username) as { id?: string } | undefined;
|
||||
.prepare(
|
||||
"SELECT id, subscription_id FROM proxy_registry WHERE host = ? AND port = ? AND username = ? LIMIT 1"
|
||||
)
|
||||
.get(host, port, username) as { id?: string; subscription_id?: string | null } | undefined;
|
||||
|
||||
if (existing?.id) {
|
||||
const updated = await updateProxy(existing.id, payload);
|
||||
const claimOwnership = options.claimOwnership ?? true;
|
||||
const ownerChanged = (existing.subscription_id ?? null) !== (payload.subscriptionId ?? null);
|
||||
if (!claimOwnership && ownerChanged) {
|
||||
return { proxy: null, action: "skipped" };
|
||||
}
|
||||
const { status, ...rest } = payload;
|
||||
const changes: Partial<ProxyPayload> = isProxyRegistryStatus(status)
|
||||
? { ...rest, status }
|
||||
: rest;
|
||||
const updated = await updateProxy(existing.id, changes);
|
||||
return { proxy: updated, action: "updated" };
|
||||
}
|
||||
|
||||
@@ -358,6 +385,8 @@ export async function upsertProxy(payload: ProxyPayload): Promise<{
|
||||
}
|
||||
|
||||
export async function updateProxy(id: string, payload: Partial<ProxyPayload>) {
|
||||
// No status filtering here: callers own the status they send. Writes that must
|
||||
// preserve the stored status filter it in upsertProxy before calling this.
|
||||
const db = getDbInstance();
|
||||
const existing = await getProxyById(id, { includeSecrets: true });
|
||||
if (!existing) return null;
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
addProxiesToScopePool,
|
||||
bumpProxyRegistryGeneration,
|
||||
deleteProxyById,
|
||||
updateProxy,
|
||||
upsertProxy,
|
||||
} from "../db/proxies";
|
||||
import { bumpProxyConfigGeneration } from "../db/settings";
|
||||
@@ -384,6 +385,23 @@ async function fetchSubscriptionContent(url: string): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep a synced node only when this subscription owns its registry row. A row created
|
||||
* by hand, or owned by another subscription, comes back as "skipped" and stays out of
|
||||
* this pool. An owned row that pool validation flagged `error` is healed, since the feed
|
||||
* just listed it again; `inactive` and `dead` are operator or health decisions and stay.
|
||||
*/
|
||||
async function keepOwnedSyncedRow(
|
||||
upserted: Awaited<ReturnType<typeof upsertProxy>>,
|
||||
keptIds: string[]
|
||||
): Promise<void> {
|
||||
if (upserted.action === "skipped" || !upserted.proxy?.id) return;
|
||||
keptIds.push(upserted.proxy.id);
|
||||
if (upserted.proxy.status === "error") {
|
||||
await updateProxy(upserted.proxy.id, { status: "active" });
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch + parse + sync nodes into proxy_registry, then (if enabled) (re)bind. */
|
||||
async function syncSubscriptionUnsafe(id: string): Promise<SyncResult> {
|
||||
const sub = await getSubscriptionById(id);
|
||||
@@ -416,18 +434,21 @@ async function syncSubscriptionUnsafe(id: string): Promise<SyncResult> {
|
||||
try {
|
||||
// Directly-usable nodes → upsert into the registry as a pool.
|
||||
for (const node of parsed.nodes) {
|
||||
const upserted = await upsertProxy({
|
||||
name: node.name || `${sub.name} (${node.host}:${node.port})`,
|
||||
type: node.type,
|
||||
host: node.host,
|
||||
port: node.port,
|
||||
username: node.username,
|
||||
password: node.password,
|
||||
source: "subscription",
|
||||
subscriptionId: id,
|
||||
status: "active",
|
||||
});
|
||||
if (upserted.proxy?.id) keptIds.push(upserted.proxy.id);
|
||||
// No status: a refresh must not revive a node the operator or auto-disable turned off.
|
||||
const upserted = await upsertProxy(
|
||||
{
|
||||
name: node.name || `${sub.name} (${node.host}:${node.port})`,
|
||||
type: node.type,
|
||||
host: node.host,
|
||||
port: node.port,
|
||||
username: node.username,
|
||||
password: node.password,
|
||||
source: "subscription",
|
||||
subscriptionId: id,
|
||||
},
|
||||
{ claimOwnership: false }
|
||||
);
|
||||
await keepOwnedSyncedRow(upserted, keptIds);
|
||||
}
|
||||
|
||||
// needsCore nodes → bind the operator-supplied local core endpoint (single).
|
||||
@@ -436,18 +457,20 @@ async function syncSubscriptionUnsafe(id: string): Promise<SyncResult> {
|
||||
try {
|
||||
const coreUrl = new URL(sub.localCoreEndpoint);
|
||||
const coreType = coreUrl.protocol === "https:" ? "https" : coreUrl.protocol === "socks5:" ? "socks5" : "http";
|
||||
const upserted = await upsertProxy({
|
||||
name: `${sub.name} (local core)`,
|
||||
type: coreType,
|
||||
host: coreUrl.hostname,
|
||||
port: Number(coreUrl.port) || (coreType === "https" ? 443 : 8080),
|
||||
username: coreUrl.username ? decodeURIComponent(coreUrl.username) : undefined,
|
||||
password: coreUrl.password ? decodeURIComponent(coreUrl.password) : undefined,
|
||||
source: "subscription",
|
||||
subscriptionId: id,
|
||||
status: "active",
|
||||
});
|
||||
if (upserted.proxy?.id) keptIds.push(upserted.proxy.id);
|
||||
const upserted = await upsertProxy(
|
||||
{
|
||||
name: `${sub.name} (local core)`,
|
||||
type: coreType,
|
||||
host: coreUrl.hostname,
|
||||
port: Number(coreUrl.port) || (coreType === "https" ? 443 : 8080),
|
||||
username: coreUrl.username ? decodeURIComponent(coreUrl.username) : undefined,
|
||||
password: coreUrl.password ? decodeURIComponent(coreUrl.password) : undefined,
|
||||
source: "subscription",
|
||||
subscriptionId: id,
|
||||
},
|
||||
{ claimOwnership: false }
|
||||
);
|
||||
await keepOwnedSyncedRow(upserted, keptIds);
|
||||
} catch {
|
||||
warning = subscriptionErrorCode("LOCAL_CORE_ENDPOINT_INVALID");
|
||||
}
|
||||
|
||||
13
src/shared/constants/proxyRegistryStatus.ts
Normal file
13
src/shared/constants/proxyRegistryStatus.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Statuses a caller may write on a proxy registry row. `error` is deliberately not
|
||||
* listed: only pool validation sets it, and no import or update may send it.
|
||||
*/
|
||||
export const PROXY_REGISTRY_STATUS_VALUES = ["active", "inactive", "dead"] as const;
|
||||
|
||||
export type ProxyRegistryStatus = (typeof PROXY_REGISTRY_STATUS_VALUES)[number];
|
||||
|
||||
export function isProxyRegistryStatus(value: unknown): value is ProxyRegistryStatus {
|
||||
return (
|
||||
typeof value === "string" && (PROXY_REGISTRY_STATUS_VALUES as readonly string[]).includes(value)
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
isForbiddenCustomHeaderName,
|
||||
} from "@/shared/constants/upstreamHeaders";
|
||||
import { MAX_TIMER_TIMEOUT_MS } from "@/shared/utils/runtimeTimeouts";
|
||||
import { PROXY_REGISTRY_STATUS_VALUES } from "@/shared/constants/proxyRegistryStatus";
|
||||
|
||||
export const proxyConfigSchema = z
|
||||
.object({
|
||||
@@ -116,7 +117,9 @@ export const proxyRegistryFieldsSchema = z
|
||||
password: z.string().optional(),
|
||||
region: z.string().trim().max(64).nullable().optional(),
|
||||
notes: z.string().trim().max(1000).nullable().optional(),
|
||||
status: z.enum(["active", "inactive", "dead"]).optional().default("active"),
|
||||
// No default: zod 4 applies it under .partial() too, which rewrote the stored status
|
||||
// on every update or import that omitted it. New rows still start active in the DB.
|
||||
status: z.enum(PROXY_REGISTRY_STATUS_VALUES).optional(),
|
||||
source: z
|
||||
.enum([
|
||||
"manual",
|
||||
|
||||
@@ -14,7 +14,7 @@ test("auth-less host:port produces socks5 entry with generated name (default typ
|
||||
assert.equal(e.type, "socks5");
|
||||
assert.equal(e.username, "");
|
||||
assert.equal(e.password, "");
|
||||
assert.equal(e.status, "active");
|
||||
assert.equal("status" in e, false);
|
||||
assert.match(e.name, /127\.0\.0\.1:7897/);
|
||||
});
|
||||
|
||||
@@ -241,7 +241,7 @@ test("pipe-delimited minimal NAME|HOST|PORT defaults type to socks5", () => {
|
||||
assert.equal(errors.length, 0);
|
||||
assert.equal(entries.length, 1);
|
||||
assert.equal(entries[0].type, "socks5");
|
||||
assert.equal(entries[0].status, "active");
|
||||
assert.equal("status" in entries[0], false);
|
||||
});
|
||||
|
||||
test("pipe-delimited missing NAME produces error", () => {
|
||||
@@ -338,3 +338,65 @@ test("bare text with no colons or pipes produces error", () => {
|
||||
assert.equal(errors.length, 1);
|
||||
assert.equal(errors[0].reason, "bulkImportErrorMissingHost");
|
||||
});
|
||||
|
||||
// ── Status is written only when the line carries one ──────────────────────────
|
||||
|
||||
test("pipe-delimited line with an empty STATUS column has no status key", () => {
|
||||
const { entries, errors } = parseBulkImportText("p|10.0.0.3|1080|||socks5|US||note");
|
||||
assert.equal(errors.length, 0);
|
||||
assert.equal("status" in entries[0], false);
|
||||
});
|
||||
|
||||
test("pipe-delimited line with STATUS=inactive keeps it", () => {
|
||||
const { entries, errors } = parseBulkImportText("p|10.0.0.4|1080|||socks5|US|inactive|note");
|
||||
assert.equal(errors.length, 0);
|
||||
assert.equal(entries[0].status, "inactive");
|
||||
});
|
||||
|
||||
test("pipe-delimited line with an unknown STATUS is rejected", () => {
|
||||
const { entries, errors } = parseBulkImportText("p|10.0.0.5|1080|||socks5|US|foo|note");
|
||||
assert.equal(entries.length, 0);
|
||||
assert.equal(errors[0].reason, "bulkImportErrorInvalidStatus");
|
||||
});
|
||||
|
||||
// ── Dashboard send payload omits a missing status ───────────────────────────
|
||||
// Mirrors the item mapping in ProxyRegistryManager (bulk import send): a line
|
||||
// without a status must reach the API without a status key, so the stored one
|
||||
// is left alone. JSON.stringify drops undefined values, which is what makes an
|
||||
// `entry.status as ... | undefined` mapping safe to send as-is.
|
||||
|
||||
function buildSendBody(entries: Array<{ [key: string]: unknown }>) {
|
||||
const payload = {
|
||||
items: entries.map((entry) => ({
|
||||
name: entry.name,
|
||||
type: entry.type,
|
||||
host: entry.host,
|
||||
port: entry.port,
|
||||
username: (entry.username as string) || undefined,
|
||||
password: (entry.password as string) || undefined,
|
||||
region: (entry.region as string) || null,
|
||||
notes: (entry.notes as string) || null,
|
||||
status: entry.status as "active" | "inactive" | undefined,
|
||||
})),
|
||||
};
|
||||
return JSON.parse(JSON.stringify(payload)) as {
|
||||
items: Array<{ [key: string]: unknown }>;
|
||||
};
|
||||
}
|
||||
|
||||
test("send payload for a line without a status carries no status key", () => {
|
||||
const { entries, errors } = parseBulkImportText("p|10.0.0.6|1080");
|
||||
assert.equal(errors.length, 0);
|
||||
assert.equal("status" in entries[0], false);
|
||||
const body = buildSendBody(entries);
|
||||
assert.equal("status" in body.items[0], false);
|
||||
});
|
||||
|
||||
test("send payload for a line with STATUS=inactive keeps inactive", () => {
|
||||
const { entries, errors } = parseBulkImportText(
|
||||
"p|10.0.0.7|1080|||socks5|US|inactive|note"
|
||||
);
|
||||
assert.equal(errors.length, 0);
|
||||
const body = buildSendBody(entries);
|
||||
assert.equal(body.items[0].status, "inactive");
|
||||
});
|
||||
|
||||
95
tests/unit/proxy-registry-status-schema.test.ts
Normal file
95
tests/unit/proxy-registry-status-schema.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
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";
|
||||
|
||||
// zod 4 applies .default() even under .partial(), so a default status on the shared
|
||||
// proxy field schema rewrote the stored status on every update or import that simply
|
||||
// omitted it. These tests pin "a status is written only when the caller sends one".
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-proxy-status-schema-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = "test-secret";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const proxiesDb = await import("../../src/lib/db/proxies.ts");
|
||||
const schemas = await import("../../src/shared/validation/schemas/proxy.ts");
|
||||
const statuses = await import("../../src/shared/constants/proxyRegistryStatus.ts");
|
||||
const { handleProxyUpdate } = await import("../../src/lib/api/proxyRegistryRouteHandlers.ts");
|
||||
|
||||
function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
});
|
||||
|
||||
test("update schema does not invent a status the client did not send", () => {
|
||||
const parsed = schemas.updateProxyRegistrySchema.parse({ id: "row-1", name: "renamed" });
|
||||
assert.equal("status" in parsed, false);
|
||||
});
|
||||
|
||||
test("bulk import schema does not invent a status the client did not send", () => {
|
||||
const parsed = schemas.bulkImportProxiesSchema.parse({
|
||||
items: [{ name: "n", host: "proxy.example.com", port: 8080 }],
|
||||
});
|
||||
assert.equal("status" in parsed.items[0], false);
|
||||
});
|
||||
|
||||
test("an explicit status is still validated against the enumeration", () => {
|
||||
const bad = schemas.bulkImportProxiesSchema.safeParse({
|
||||
items: [{ name: "n", host: "proxy.example.com", port: 8080, status: "bogus" }],
|
||||
});
|
||||
assert.equal(bad.success, false);
|
||||
const good = schemas.updateProxyRegistrySchema.parse({ id: "row-2", status: "inactive" });
|
||||
assert.equal(good.status, "inactive");
|
||||
});
|
||||
|
||||
test("isProxyRegistryStatus accepts only the enumerated statuses", () => {
|
||||
for (const value of ["active", "inactive", "dead"]) {
|
||||
assert.equal(statuses.isProxyRegistryStatus(value), true, value);
|
||||
}
|
||||
for (const value of ["", "error", "ACTIVE", undefined, null, 1]) {
|
||||
assert.equal(statuses.isProxyRegistryStatus(value), false, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
test("renaming a dead proxy through the update handler keeps it dead", async () => {
|
||||
resetStorage();
|
||||
const created = await proxiesDb.createProxy({
|
||||
name: "before",
|
||||
type: "http",
|
||||
host: "10.1.0.1",
|
||||
port: 8080,
|
||||
});
|
||||
await proxiesDb.updateProxy(created.id, { status: "dead" });
|
||||
|
||||
const response = await handleProxyUpdate(
|
||||
new Request("http://localhost/api/v1/management/proxies", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ id: created.id, name: "after" }),
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
const row = await proxiesDb.getProxyById(created.id);
|
||||
assert.equal(row?.name, "after");
|
||||
assert.equal(row?.status, "dead");
|
||||
});
|
||||
|
||||
test("creating a proxy without a status still creates it active", async () => {
|
||||
resetStorage();
|
||||
const created = await proxiesDb.createProxy({
|
||||
name: "fresh",
|
||||
type: "http",
|
||||
host: "10.1.0.2",
|
||||
port: 8080,
|
||||
});
|
||||
assert.equal((await proxiesDb.getProxyById(created.id))?.status, "active");
|
||||
});
|
||||
210
tests/unit/proxy-subscription-sync-ownership.test.ts
Normal file
210
tests/unit/proxy-subscription-sync-ownership.test.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
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";
|
||||
import http from "node:http";
|
||||
|
||||
// A subscription refresh must not revive a node the operator or auto-disable turned off,
|
||||
// and must never write a registry row it does not own (a manual proxy or another
|
||||
// subscription's node sharing the same host/port/username).
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-sub-sync-ownership-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const proxies = await import("../../src/lib/db/proxies.ts");
|
||||
const sub = await import("../../src/lib/proxySubscription/index.ts");
|
||||
|
||||
function reset() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
});
|
||||
|
||||
function feed(name: string, port: number) {
|
||||
return [
|
||||
"proxies:",
|
||||
` - name: ${name}`,
|
||||
" type: http",
|
||||
" server: 127.0.0.1",
|
||||
` port: ${port}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function startFeedServer(initialBody: string): Promise<{
|
||||
url: string;
|
||||
setBody: (body: string) => void;
|
||||
close: () => Promise<void>;
|
||||
}> {
|
||||
let body = initialBody;
|
||||
return new Promise((resolve) => {
|
||||
const srv = http.createServer((_req, res) => {
|
||||
res.writeHead(200, { "Content-Type": "text/plain" });
|
||||
res.end(body);
|
||||
});
|
||||
srv.listen(0, "127.0.0.1", () => {
|
||||
const addr = srv.address();
|
||||
if (!addr || typeof addr === "string") throw new Error("no addr");
|
||||
resolve({
|
||||
url: `http://127.0.0.1:${addr.port}/list`,
|
||||
setBody: (next) => {
|
||||
body = next;
|
||||
},
|
||||
close: () => new Promise((r) => srv.close(() => r())),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function insertSubscription(id: string, url: string) {
|
||||
const now = new Date().toISOString();
|
||||
core
|
||||
.getDbInstance()
|
||||
.prepare(
|
||||
`INSERT INTO proxy_subscriptions
|
||||
(id, name, url, enabled, mode, rule_providers, update_interval_minutes, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, 1, 'global', NULL, 60, 'empty', ?, ?)`
|
||||
)
|
||||
.run(id, `sub-${id}`, url, now, now);
|
||||
}
|
||||
|
||||
function rowIdFor(port: number) {
|
||||
const row = core
|
||||
.getDbInstance()
|
||||
.prepare("SELECT id FROM proxy_registry WHERE host = '127.0.0.1' AND port = ?")
|
||||
.get(port) as { id: string } | undefined;
|
||||
assert.ok(row, `expected a registry row on port ${port}`);
|
||||
return row.id;
|
||||
}
|
||||
|
||||
test("a refresh keeps an owned node dead and still refreshes its name", async () => {
|
||||
reset();
|
||||
const server = await startFeedServer(feed("node-v1", 18101));
|
||||
try {
|
||||
insertSubscription("s1", server.url);
|
||||
await sub.syncSubscription("s1");
|
||||
const id = rowIdFor(18101);
|
||||
await proxies.updateProxy(id, { status: "dead" });
|
||||
|
||||
server.setBody(feed("node-v2", 18101));
|
||||
await sub.syncSubscription("s1");
|
||||
|
||||
const row = await proxies.getProxyById(id);
|
||||
assert.equal(row?.status, "dead");
|
||||
assert.equal(row?.name, "node-v2");
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a refresh heals an owned node that pool validation flagged error", async () => {
|
||||
reset();
|
||||
const server = await startFeedServer(feed("node", 18102));
|
||||
try {
|
||||
insertSubscription("s1", server.url);
|
||||
await sub.syncSubscription("s1");
|
||||
const id = rowIdFor(18102);
|
||||
await proxies.updateProxy(id, { status: "error" });
|
||||
|
||||
await sub.syncSubscription("s1");
|
||||
|
||||
assert.equal((await proxies.getProxyById(id))?.status, "active");
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a new node is created active and owned by the subscription", async () => {
|
||||
reset();
|
||||
const server = await startFeedServer(feed("node", 18103));
|
||||
try {
|
||||
insertSubscription("s1", server.url);
|
||||
const result = await sub.syncSubscription("s1");
|
||||
const row = await proxies.getProxyById(rowIdFor(18103));
|
||||
assert.equal(row?.status, "active");
|
||||
assert.equal(row?.source, "subscription");
|
||||
assert.equal(row?.subscriptionId, "s1");
|
||||
assert.equal(result.boundProxies, 1);
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a manual proxy with the same tuple is not written, pooled or removed", async () => {
|
||||
reset();
|
||||
const manual = await proxies.createProxy({
|
||||
name: "manual",
|
||||
type: "https",
|
||||
host: "127.0.0.1",
|
||||
port: 18104,
|
||||
password: "manual-pass",
|
||||
status: "inactive",
|
||||
});
|
||||
const before = await proxies.getProxyById(manual.id, { includeSecrets: true });
|
||||
const server = await startFeedServer(feed("feed-node", 18104));
|
||||
try {
|
||||
insertSubscription("s1", server.url);
|
||||
const result = await sub.syncSubscription("s1");
|
||||
|
||||
assert.deepEqual(await proxies.getProxyById(manual.id, { includeSecrets: true }), before);
|
||||
assert.equal(result.boundProxies, 0);
|
||||
const pooled = core
|
||||
.getDbInstance()
|
||||
.prepare("SELECT COUNT(*) AS n FROM proxy_assignments WHERE proxy_id = ?")
|
||||
.get(manual.id) as { n: number };
|
||||
assert.equal(pooled.n, 0);
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("two subscriptions listing the same tuple: the row stays with the first one", async () => {
|
||||
reset();
|
||||
const serverA = await startFeedServer(feed("from-a", 18105));
|
||||
const serverB = await startFeedServer(feed("from-b", 18105));
|
||||
try {
|
||||
insertSubscription("sa", serverA.url);
|
||||
insertSubscription("sb", serverB.url);
|
||||
await sub.syncSubscription("sa");
|
||||
const id = rowIdFor(18105);
|
||||
const before = await proxies.getProxyById(id, { includeSecrets: true });
|
||||
|
||||
const resultB = await sub.syncSubscription("sb");
|
||||
|
||||
assert.deepEqual(await proxies.getProxyById(id, { includeSecrets: true }), before);
|
||||
assert.equal(before?.subscriptionId, "sa");
|
||||
assert.equal(resultB.boundProxies, 0);
|
||||
} finally {
|
||||
await serverA.close();
|
||||
await serverB.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a refresh keeps the dead local core row a needs-core feed binds", async () => {
|
||||
reset();
|
||||
const server = await startFeedServer("ss://YWVzLTI1Ni1nY206cGFzcw@203.0.113.9:8388#ss-node");
|
||||
try {
|
||||
insertSubscription("s1", server.url);
|
||||
core
|
||||
.getDbInstance()
|
||||
.prepare("UPDATE proxy_subscriptions SET local_core_endpoint = ? WHERE id = ?")
|
||||
.run("socks5://127.0.0.1:18106", "s1");
|
||||
const first = await sub.syncSubscription("s1");
|
||||
const id = rowIdFor(18106);
|
||||
assert.equal(first.boundProxies, 1);
|
||||
assert.equal((await proxies.getProxyById(id))?.subscriptionId, "s1");
|
||||
await proxies.updateProxy(id, { status: "dead" });
|
||||
|
||||
await sub.syncSubscription("s1");
|
||||
|
||||
assert.equal((await proxies.getProxyById(id))?.status, "dead");
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
141
tests/unit/proxy-upsert-preserves-status.test.ts
Normal file
141
tests/unit/proxy-upsert-preserves-status.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
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";
|
||||
|
||||
// upsertProxy is the single writer shared by bulk import and subscription sync. A write
|
||||
// that carries no valid status must leave the stored one alone, and the sync must be
|
||||
// able to refuse writing a row it does not own.
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-proxy-upsert-status-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const proxiesDb = await import("../../src/lib/db/proxies.ts");
|
||||
|
||||
function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
resetStorage();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
});
|
||||
|
||||
const tuple = { type: "http", host: "10.2.0.1", port: 8080, username: "u" };
|
||||
|
||||
async function seed(status?: string) {
|
||||
const created = await proxiesDb.upsertProxy({ name: "seed", ...tuple, password: "old" });
|
||||
assert.equal(created.action, "created");
|
||||
const id = created.proxy!.id;
|
||||
if (status) await proxiesDb.updateProxy(id, { status });
|
||||
return id;
|
||||
}
|
||||
|
||||
test("a new row without a status is created active", async () => {
|
||||
const id = await seed();
|
||||
assert.equal((await proxiesDb.getProxyById(id))?.status, "active");
|
||||
});
|
||||
|
||||
test("re-importing a disabled proxy without a status keeps it disabled", async () => {
|
||||
const id = await seed("inactive");
|
||||
const again = await proxiesDb.upsertProxy({ name: "seed", ...tuple, password: "new" });
|
||||
assert.equal(again.action, "updated");
|
||||
const row = await proxiesDb.getProxyById(id, { includeSecrets: true });
|
||||
assert.equal(row?.status, "inactive");
|
||||
assert.equal(row?.password, "new");
|
||||
});
|
||||
|
||||
test("an explicit status on re-import is applied", async () => {
|
||||
const id = await seed();
|
||||
await proxiesDb.upsertProxy({ name: "seed", ...tuple, status: "inactive" });
|
||||
assert.equal((await proxiesDb.getProxyById(id))?.status, "inactive");
|
||||
});
|
||||
|
||||
test("an empty, unknown or undefined status on an existing row is ignored", async () => {
|
||||
const id = await seed("dead");
|
||||
await proxiesDb.upsertProxy({ name: "seed", ...tuple, status: "" });
|
||||
await proxiesDb.upsertProxy({ name: "seed", ...tuple, status: "bogus" });
|
||||
await proxiesDb.upsertProxy({ name: "seed", ...tuple, status: undefined });
|
||||
assert.equal((await proxiesDb.getProxyById(id))?.status, "dead");
|
||||
});
|
||||
|
||||
test("a row flagged error stays in error on re-import", async () => {
|
||||
const id = await seed("error");
|
||||
await proxiesDb.upsertProxy({ name: "seed", ...tuple });
|
||||
assert.equal((await proxiesDb.getProxyById(id))?.status, "error");
|
||||
});
|
||||
|
||||
test("an explicit source on import is still applied by default", async () => {
|
||||
const id = await seed();
|
||||
await proxiesDb.upsertProxy({ name: "seed", ...tuple, source: "oneproxy" });
|
||||
assert.equal((await proxiesDb.getProxyById(id))?.source, "oneproxy");
|
||||
});
|
||||
|
||||
test("claimOwnership false leaves a manual row completely untouched", async () => {
|
||||
const id = await seed("dead");
|
||||
const before = await proxiesDb.getProxyById(id, { includeSecrets: true });
|
||||
|
||||
const result = await proxiesDb.upsertProxy(
|
||||
{
|
||||
name: "feed node",
|
||||
...tuple,
|
||||
type: "socks5",
|
||||
password: "feed-pass",
|
||||
source: "subscription",
|
||||
subscriptionId: "sub-b",
|
||||
},
|
||||
{ claimOwnership: false }
|
||||
);
|
||||
|
||||
assert.equal(result.action, "skipped");
|
||||
assert.equal(result.proxy, null);
|
||||
assert.deepEqual(await proxiesDb.getProxyById(id, { includeSecrets: true }), before);
|
||||
});
|
||||
|
||||
test("claimOwnership false leaves another subscription's row untouched", async () => {
|
||||
const created = await proxiesDb.upsertProxy({
|
||||
name: "owned by a",
|
||||
...tuple,
|
||||
source: "subscription",
|
||||
subscriptionId: "sub-a",
|
||||
});
|
||||
const id = created.proxy!.id;
|
||||
const before = await proxiesDb.getProxyById(id, { includeSecrets: true });
|
||||
|
||||
const result = await proxiesDb.upsertProxy(
|
||||
{ name: "claimed by b", ...tuple, source: "subscription", subscriptionId: "sub-b" },
|
||||
{ claimOwnership: false }
|
||||
);
|
||||
|
||||
assert.equal(result.action, "skipped");
|
||||
assert.deepEqual(await proxiesDb.getProxyById(id, { includeSecrets: true }), before);
|
||||
});
|
||||
|
||||
test("claimOwnership false still updates a row the same subscription owns", async () => {
|
||||
const created = await proxiesDb.upsertProxy({
|
||||
name: "old name",
|
||||
...tuple,
|
||||
source: "subscription",
|
||||
subscriptionId: "sub-a",
|
||||
});
|
||||
const id = created.proxy!.id;
|
||||
await proxiesDb.updateProxy(id, { status: "dead" });
|
||||
|
||||
const result = await proxiesDb.upsertProxy(
|
||||
{ name: "new name", ...tuple, source: "subscription", subscriptionId: "sub-a" },
|
||||
{ claimOwnership: false }
|
||||
);
|
||||
|
||||
assert.equal(result.action, "updated");
|
||||
const row = await proxiesDb.getProxyById(id);
|
||||
assert.equal(row?.name, "new name");
|
||||
assert.equal(row?.status, "dead");
|
||||
});
|
||||
Reference in New Issue
Block a user