Files
OmniRoute/tests/unit/proxy-registry-manager.test.ts
Dizzle aaf0777a98 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!
2026-09-15 11:05:01 -03:00

403 lines
16 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
import { parseBulkImportText } from "../../src/app/(dashboard)/dashboard/settings/components/parseBulkProxyImport.ts";
// ── 2-part auth-less shorthand: host:port ─────────────────────────────────────
test("auth-less host:port produces socks5 entry with generated name (default type changed from http to socks5)", () => {
const { entries, errors } = parseBulkImportText("127.0.0.1:7897");
assert.equal(errors.length, 0);
assert.equal(entries.length, 1);
const e = entries[0];
assert.equal(e.host, "127.0.0.1");
assert.equal(e.port, 7897);
assert.equal(e.type, "socks5");
assert.equal(e.username, "");
assert.equal(e.password, "");
assert.equal("status" in e, false);
assert.match(e.name, /127\.0\.0\.1:7897/);
});
test("auth-less host:port with hostname (not IP)", () => {
const { entries, errors } = parseBulkImportText("proxy.example.com:3128");
assert.equal(errors.length, 0);
assert.equal(entries.length, 1);
assert.equal(entries[0].host, "proxy.example.com");
assert.equal(entries[0].port, 3128);
});
test("auth-less host:port with port 0 produces error", () => {
const { entries, errors } = parseBulkImportText("127.0.0.1:0");
assert.equal(entries.length, 0);
assert.equal(errors.length, 1);
assert.equal(errors[0].reason, "bulkImportErrorInvalidPort");
});
test("auth-less host:port with port > 65535 produces error", () => {
const { entries, errors } = parseBulkImportText("127.0.0.1:99999");
assert.equal(entries.length, 0);
assert.equal(errors.length, 1);
assert.equal(errors[0].reason, "bulkImportErrorInvalidPort");
});
test("auth-less host:port with non-numeric port produces error", () => {
const { entries, errors } = parseBulkImportText("127.0.0.1:abc");
assert.equal(entries.length, 0);
assert.equal(errors.length, 1);
assert.equal(errors[0].reason, "bulkImportErrorInvalidPort");
});
// ── 4-part shorthand: ip:port:user:pass ───────────────────────────────────────
test("ip:port:user:pass parses correctly", () => {
const { entries, errors } = parseBulkImportText("138.99.147.218:50101:myuser:mypass");
assert.equal(errors.length, 0);
assert.equal(entries.length, 1);
const e = entries[0];
assert.equal(e.host, "138.99.147.218");
assert.equal(e.port, 50101);
assert.equal(e.username, "myuser");
assert.equal(e.password, "mypass");
assert.equal(e.type, "socks5");
assert.match(e.name, /138\.99\.147\.218:50101/);
});
test("ip:port:user:pass with hostname works", () => {
const { entries, errors } = parseBulkImportText("proxy.example.com:3128:user:pass");
assert.equal(errors.length, 0);
assert.equal(entries.length, 1);
assert.equal(entries[0].host, "proxy.example.com");
assert.equal(entries[0].username, "user");
assert.equal(entries[0].password, "pass");
});
// ── @-style shorthand: user:pass@ip:port ─────────────────────────────────────
test("user:pass@ip:port parses correctly", () => {
const { entries, errors } = parseBulkImportText("myuser:mypass@138.99.147.218:50101");
assert.equal(errors.length, 0);
assert.equal(entries.length, 1);
const e = entries[0];
assert.equal(e.host, "138.99.147.218");
assert.equal(e.port, 50101);
assert.equal(e.username, "myuser");
assert.equal(e.password, "mypass");
assert.equal(e.type, "socks5");
});
test("user:pass@hostname:port parses correctly", () => {
const { entries, errors } = parseBulkImportText("admin:secret@proxy.example.com:443");
assert.equal(errors.length, 0);
assert.equal(entries.length, 1);
assert.equal(entries[0].host, "proxy.example.com");
assert.equal(entries[0].port, 443);
assert.equal(entries[0].username, "admin");
assert.equal(entries[0].password, "secret");
});
// ── user:pass:ip:port shorthand ───────────────────────────────────────────────
test("user:pass:ip:port parses correctly", () => {
const { entries, errors } = parseBulkImportText("myuser:mypass:138.99.147.218:50101");
assert.equal(errors.length, 0);
assert.equal(entries.length, 1);
const e = entries[0];
assert.equal(e.host, "138.99.147.218");
assert.equal(e.port, 50101);
assert.equal(e.username, "myuser");
assert.equal(e.password, "mypass");
});
// ── protocol:// shorthand ──────────────────────────────────────────────────────
test("protocol://ip:port parses with explicit type", () => {
const { entries, errors } = parseBulkImportText("http://10.0.0.50:8080");
assert.equal(errors.length, 0);
assert.equal(entries.length, 1);
const e = entries[0];
assert.equal(e.host, "10.0.0.50");
assert.equal(e.port, 8080);
assert.equal(e.type, "http");
assert.equal(e.username, "");
assert.equal(e.password, "");
});
test("socks5://ip:port parses with explicit type", () => {
const { entries, errors } = parseBulkImportText("socks5://1.2.3.4:1080");
assert.equal(errors.length, 0);
assert.equal(entries.length, 1);
assert.equal(entries[0].type, "socks5");
assert.equal(entries[0].host, "1.2.3.4");
assert.equal(entries[0].port, 1080);
});
test("https://ip:port parses with explicit type", () => {
const { entries, errors } = parseBulkImportText("https://proxy.example.com:443");
assert.equal(errors.length, 0);
assert.equal(entries.length, 1);
assert.equal(entries[0].type, "https");
assert.equal(entries[0].host, "proxy.example.com");
assert.equal(entries[0].port, 443);
});
test("protocol://user:pass@ip:port parses with auth + explicit type", () => {
const { entries, errors } = parseBulkImportText("https://admin:secret123@proxy.example.com:443");
assert.equal(errors.length, 0);
assert.equal(entries.length, 1);
const e = entries[0];
assert.equal(e.type, "https");
assert.equal(e.host, "proxy.example.com");
assert.equal(e.port, 443);
assert.equal(e.username, "admin");
assert.equal(e.password, "secret123");
});
test("http://user:pass@ip:port parses correctly", () => {
const { entries, errors } = parseBulkImportText("http://user:pass@10.0.0.50:8080");
assert.equal(errors.length, 0);
assert.equal(entries.length, 1);
assert.equal(entries[0].type, "http");
assert.equal(entries[0].username, "user");
assert.equal(entries[0].password, "pass");
});
// ── Protocol header mode ───────────────────────────────────────────────────────
test("protocol header sets default type for subsequent shorthand lines", () => {
const text = [
"http",
"1.2.3.4:8080",
"5.6.7.8:3128:user:pass",
"user:pass@9.10.11.12:443",
].join("\n");
const { entries, errors } = parseBulkImportText(text);
assert.equal(errors.length, 0);
assert.equal(entries.length, 3);
assert.equal(entries[0].type, "http");
assert.equal(entries[1].type, "http");
assert.equal(entries[2].type, "http");
});
test("protocol:// prefix overrides protocol header default", () => {
const text = [
"socks5",
"http://1.2.3.4:8080",
"1.2.3.4:1080",
].join("\n");
const { entries, errors } = parseBulkImportText(text);
assert.equal(errors.length, 0);
assert.equal(entries.length, 2);
assert.equal(entries[0].type, "http", "explicit protocol:// must override header");
assert.equal(entries[1].type, "socks5", "no-prefix line falls back to header default");
});
test("protocol header mode with mixed shorthand and pipe formats", () => {
const text = [
"https",
"1.2.3.4:443",
"named-proxy|5.6.7.8|8080|||socks5||active|pipe entry keeps own type",
].join("\n");
const { entries, errors } = parseBulkImportText(text);
assert.equal(errors.length, 0);
assert.equal(entries.length, 2);
assert.equal(entries[0].type, "https", "shorthand inherits header");
assert.equal(entries[1].type, "socks5", "pipe entry keeps its own TYPE field");
});
test("protocol header only affects lines after it", () => {
const text = [
"1.2.3.4:1080",
"http",
"1.2.3.4:8080",
].join("\n");
const { entries, errors } = parseBulkImportText(text);
assert.equal(errors.length, 0);
assert.equal(entries.length, 2);
assert.equal(entries[0].type, "socks5", "line before header gets default socks5");
assert.equal(entries[1].type, "http", "line after header gets http");
});
// ── Regression: pipe-delimited full format still works ────────────────────────
test("pipe-delimited NAME|HOST|PORT with all optional fields", () => {
const line = "my-proxy|10.0.0.1|8080|user|pass|http|US|active|notes here";
const { entries, errors } = parseBulkImportText(line);
assert.equal(errors.length, 0);
assert.equal(entries.length, 1);
const e = entries[0];
assert.equal(e.name, "my-proxy");
assert.equal(e.host, "10.0.0.1");
assert.equal(e.port, 8080);
assert.equal(e.username, "user");
assert.equal(e.password, "pass");
assert.equal(e.type, "http");
assert.equal(e.region, "US");
assert.equal(e.status, "active");
assert.equal(e.notes, "notes here");
});
test("pipe-delimited minimal NAME|HOST|PORT defaults type to socks5", () => {
const { entries, errors } = parseBulkImportText("p|10.0.0.2|1080");
assert.equal(errors.length, 0);
assert.equal(entries.length, 1);
assert.equal(entries[0].type, "socks5");
assert.equal("status" in entries[0], false);
});
test("pipe-delimited missing NAME produces error", () => {
const { errors } = parseBulkImportText("|10.0.0.1|8080");
assert.equal(errors.length, 1);
assert.equal(errors[0].reason, "bulkImportErrorMissingName");
});
test("pipe-delimited invalid port produces error", () => {
const { errors } = parseBulkImportText("proxy|10.0.0.1|notaport");
assert.equal(errors.length, 1);
assert.equal(errors[0].reason, "bulkImportErrorInvalidPort");
});
test("pipe-delimited invalid type produces error", () => {
const { errors } = parseBulkImportText("p|10.0.0.1|8080|||ftp");
assert.equal(errors.length, 1);
assert.equal(errors[0].reason, "bulkImportErrorInvalidType");
});
// ── Mixed lines ───────────────────────────────────────────────────────────────
test("mixed: comment lines and blank lines are skipped", () => {
const text = [
"# this is a comment",
"",
"127.0.0.1:7897",
"# another comment",
"proxy-us|10.0.0.1|3128",
].join("\n");
const { entries, errors, skipped } = parseBulkImportText(text);
assert.equal(errors.length, 0);
assert.equal(entries.length, 2);
assert.equal(skipped, 3);
assert.equal(entries[0].host, "127.0.0.1");
assert.equal(entries[1].host, "10.0.0.1");
});
test("multiple auth-less entries in one block", () => {
const text = ["10.0.0.1:1080", "10.0.0.2:3128", "10.0.0.3:8888"].join("\n");
const { entries, errors } = parseBulkImportText(text);
assert.equal(errors.length, 0);
assert.equal(entries.length, 3);
assert.equal(entries[0].port, 1080);
assert.equal(entries[1].port, 3128);
assert.equal(entries[2].port, 8888);
});
test("full real-world mixed import block", () => {
const text = [
"# My proxy list",
"socks5",
"138.99.147.218:50101:myuser:mypass",
"200.234.177.62:50101:otheruser:otherpass",
"http://10.0.0.50:8080",
"https://admin:secret@proxy.example.com:443",
"",
"named-proxy|5.6.7.8|3128|user|pass|http|US|active|via pipe",
].join("\n");
const { entries, errors, skipped } = parseBulkImportText(text);
assert.equal(errors.length, 0);
assert.equal(entries.length, 5);
assert.equal(skipped, 2); // comment + blank line
assert.equal(entries[0].type, "socks5");
assert.equal(entries[1].type, "socks5");
assert.equal(entries[2].type, "http");
assert.equal(entries[3].type, "https");
assert.equal(entries[4].type, "http");
assert.equal(entries[4].name, "named-proxy");
});
// ── Edge cases ─────────────────────────────────────────────────────────────────
test("4-colon ambiguous line where part0 is not host-like defaults to user:pass:ip:port", () => {
const { entries, errors } = parseBulkImportText("myuser:mypass:1.2.3.4:1080");
assert.equal(errors.length, 0);
assert.equal(entries.length, 1);
assert.equal(entries[0].host, "1.2.3.4");
assert.equal(entries[0].port, 1080);
assert.equal(entries[0].username, "myuser");
assert.equal(entries[0].password, "mypass");
});
test("single colon without port number produces error", () => {
const { entries, errors } = parseBulkImportText("justtext:nonsense");
assert.equal(entries.length, 0);
assert.equal(errors.length, 1);
assert.equal(errors[0].reason, "bulkImportErrorInvalidPort");
});
test("bare text with no colons or pipes produces error", () => {
const { entries, errors } = parseBulkImportText("justtext");
assert.equal(entries.length, 0);
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");
});