feat: 支持自定义订阅节点并兼容现有数据库

This commit is contained in:
2026-09-29 11:52:41 +08:00
parent 3faf960f35
commit ab7d1c937a
27 changed files with 2203 additions and 449 deletions
+133
View File
@@ -0,0 +1,133 @@
import { test, expect } from "./e2e-fixtures";
import { parse } from "yaml";
test("custom YAML nodes use compact forms, persist edits and integrate with subscription groups", async ({
page,
}) => {
const errors: string[] = [];
page.on("pageerror", (e) => errors.push(e.message));
await page.goto("/login");
await page.getByLabel("用户名", { exact: true }).fill("ui-review");
await page.getByLabel("密码", { exact: true }).fill("ui-review-local-2026");
await page.getByRole("button", { name: "登录", exact: true }).click();
await expect(page).toHaveURL(/dashboard/);
await page.getByRole("link", { name: "节点管理" }).click();
const create = page
.locator(".compact-create-panel details")
.filter({ has: page.getByRole("heading", { name: "新增自定义订阅节点" }) });
await create.locator(":scope > summary").click();
await create.getByLabel("Slug", { exact: true }).fill("custom-browser");
await create.getByLabel("显示名", { exact: true }).fill("香港自定义节点");
await create.getByRole("textbox", { name: "节点 YAML", exact: true }).fill("name: [invalid");
await create.getByRole("button", { name: "创建自定义节点" }).click();
await expect(create.getByRole("alert")).toContainText("YAML 格式错误");
await expect(create.getByRole("textbox", { name: "节点 YAML", exact: true })).toHaveValue(
"name: [invalid",
);
const source =
'# keep this comment\nproxies:\n - name: \'HK "AnyTLS": #1\'\n type: anytls\n server: hk.example.com\n port: 443\n password: "test-only:password"\n udp: true\n';
await create.getByRole("textbox", { name: "节点 YAML", exact: true }).fill(source);
await create.getByRole("button", { name: "创建自定义节点" }).click();
const row = page
.locator(".entity-table__item")
.filter({ hasText: "custom-browser" });
await expect(row).toBeVisible();
await expect(row).toContainText("不轮询");
await row.locator(":scope > summary").click();
await expect(row.getByRole("textbox", { name: "节点 YAML", exact: true })).toHaveValue(
source,
);
await expect(row.getByLabel("客户端地址", { exact: true })).toHaveCount(0);
const updated = source.replace('HK "AnyTLS": #1', 'HK "AnyTLS": #2');
await row.getByRole("textbox", { name: "节点 YAML", exact: true }).fill(updated);
await row.getByRole("button", { name: "保存自定义节点" }).click();
await expect(row.locator("p[role=status]")).toHaveText("已保存");
await page.reload();
await row.locator(":scope > summary").click();
await expect(
row.getByRole("textbox", { name: "节点 YAML", exact: true }),
).toHaveValue(updated);
await page.screenshot({
path: "test-results/custom-node-desktop.png",
fullPage: true,
});
const config = await page.request.get(
"/api/admin/nodes/custom-browser/config",
);
expect(config.status()).toBe(409);
const auth = await page.request.post("/api/hy2/nodes/custom-browser/auth", {
data: { auth: "test:test" },
});
expect(await auth.json()).toMatchObject({ ok: false });
await page.getByRole("link", { name: "订阅配置" }).click();
const main = page.locator(".proxy-group-card").first();
await expect(main.locator(".proxy-sort-item")).toContainText(
'HK "AnyTLS": #2',
);
const createGroup = page
.locator("details")
.filter({ has: page.locator("summary", { hasText: "新建分组" }) });
await createGroup.locator(":scope > summary").click();
await createGroup.getByLabel("新建分组名").fill("Custom Work");
await createGroup.getByRole("button", { name: /HK.*添加/ }).click();
await createGroup
.getByRole("button", { name: "新建分组", exact: true })
.click();
await expect(
page.locator("summary strong", { hasText: "Custom Work" }),
).toBeVisible();
const url = await page.locator(".copy-line code").first().textContent();
const response = await page.request.get(url!);
expect(response.status()).toBe(200);
const subscription = parse(await response.text());
const proxy = subscription.proxies.find(
(p: { type: string }) => p.type === "anytls",
);
expect(proxy).toMatchObject({
name: 'HK "AnyTLS": #2',
server: "hk.example.com",
password: "test-only:password",
udp: true,
});
expect(
subscription["proxy-groups"].find(
(g: { name: string }) => g.name === "Custom Work",
).proxies,
).toEqual([proxy.name]);
const group = page
.locator("details")
.filter({
has: page.locator("summary strong", { hasText: "Custom Work" }),
});
await group.locator(":scope > summary").click();
await group.getByRole("button", { name: "删除分组" }).click();
await page.getByRole("button", { name: "确认删除分组" }).click();
await expect(group).toHaveCount(0);
await page.getByRole("link", { name: "节点管理" }).click();
await page.getByRole("searchbox").fill("custom");
await expect(page.locator(".entity-table__item:visible")).toHaveCount(1);
await row.locator(":scope > summary").click();
await page.setViewportSize({ width: 390, height: 844 });
await expect
.poll(() =>
page.evaluate(() => document.documentElement.scrollWidth <= innerWidth),
)
.toBe(true);
await page.screenshot({
path: "test-results/custom-node-mobile.png",
fullPage: true,
});
await row.getByLabel("启用节点", { exact: true }).uncheck();
await row.getByRole("button", { name: "保存自定义节点" }).click();
await expect(row.locator("p[role=status]")).toHaveText("已保存");
const disabled = parse(await (await page.request.get(url!)).text());
expect(
disabled.proxies.some((p: { type: string }) => p.type === "anytls"),
).toBe(false);
await row.getByRole("button", { name: "删除节点" }).click();
await page.getByRole("button", { name: "确认删除节点" }).click();
await expect(row).toHaveCount(0);
expect(errors).toEqual([]);
});
+18
View File
@@ -0,0 +1,18 @@
import { test as base, expect } from "@playwright/test";
import Database from "better-sqlite3";
export const test = base.extend<{ isolatedData: void }>({
isolatedData: [async ({}, use) => {
const file = process.env.HY2_E2E_DATABASE;
if (!file || !/^\/tmp\/hy2-panel-ui-test-\d+\.db$/.test(file)) throw new Error("Refusing to reset a non-test database");
const db = new Database(file, { fileMustExist: true });
db.pragma("foreign_keys = ON");
db.transaction(() => {
db.exec("DELETE FROM hy2_nodes; DELETE FROM hy2_users WHERE is_admin = 0;");
db.prepare("UPDATE app_settings SET value = '[]' WHERE key IN ('main_proxy_group_member_slugs_json','proxy_groups_json')").run();
})();
db.close();
await use();
}, { auto: true }],
});
export { expect };
+124
View File
@@ -0,0 +1,124 @@
-- Schema only, inspected on us3 on 2026-09-29. No production data.
CREATE TABLE admins (
id INTEGER PRIMARY KEY CHECK (id = 1),
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE admin_sessions (
id TEXT PRIMARY KEY,
admin_id INTEGER NOT NULL,
session_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
FOREIGN KEY (admin_id) REFERENCES admins(id) ON DELETE CASCADE
);
CREATE TABLE hy2_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
auth_id TEXT NOT NULL UNIQUE,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
expires_at TEXT,
traffic_limit_bytes INTEGER,
used_tx_bytes INTEGER NOT NULL DEFAULT 0,
used_rx_bytes INTEGER NOT NULL DEFAULT 0,
notes TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
, is_admin INTEGER NOT NULL DEFAULT 0, auth_secret_encrypted TEXT, subscription_token TEXT, subscription_label TEXT NOT NULL DEFAULT '');
CREATE TABLE hy2_nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
auth_token TEXT NOT NULL,
traffic_stats_url TEXT NOT NULL,
traffic_stats_secret TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
poll_interval_seconds INTEGER NOT NULL DEFAULT 15,
last_polled_at TEXT,
last_sync_ok_at TEXT,
last_error_at TEXT,
last_error_message TEXT,
last_online_users INTEGER NOT NULL DEFAULT 0,
last_stream_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
, server_host TEXT NOT NULL DEFAULT '', server_port INTEGER NOT NULL DEFAULT 443, client_name TEXT NOT NULL DEFAULT '', bandwidth_up_mbps INTEGER NOT NULL DEFAULT 30, bandwidth_down_mbps INTEGER NOT NULL DEFAULT 30, skip_cert_verify INTEGER NOT NULL DEFAULT 0, sni TEXT, traffic_stats_listen TEXT NOT NULL DEFAULT ':9999', hy2_listen TEXT NOT NULL DEFAULT ':443', node_type TEXT NOT NULL DEFAULT 'hy2', custom_config_yaml TEXT NOT NULL DEFAULT '');
CREATE TABLE traffic_ledgers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
node_id INTEGER NOT NULL,
user_id INTEGER,
auth_id TEXT NOT NULL,
tx_bytes INTEGER NOT NULL,
rx_bytes INTEGER NOT NULL,
window_started_at TEXT NOT NULL,
window_ended_at TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (node_id) REFERENCES hy2_nodes(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES hy2_users(id) ON DELETE SET NULL
);
CREATE TABLE node_sync_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
node_id INTEGER NOT NULL,
started_at TEXT NOT NULL,
finished_at TEXT NOT NULL,
ok INTEGER NOT NULL,
traffic_entries INTEGER NOT NULL DEFAULT 0,
online_users INTEGER NOT NULL DEFAULT 0,
stream_count INTEGER NOT NULL DEFAULT 0,
kicked_auth_ids TEXT NOT NULL DEFAULT '',
error_message TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY (node_id) REFERENCES hy2_nodes(id) ON DELETE CASCADE
);
CREATE TABLE auth_audits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
node_id INTEGER,
user_id INTEGER,
auth_id TEXT,
username TEXT,
addr TEXT,
requested_tx INTEGER,
ok INTEGER NOT NULL,
reason TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (node_id) REFERENCES hy2_nodes(id) ON DELETE SET NULL,
FOREIGN KEY (user_id) REFERENCES hy2_users(id) ON DELETE SET NULL
);
CREATE TABLE node_user_presence (
node_id INTEGER NOT NULL,
user_id INTEGER,
auth_id TEXT NOT NULL,
connections INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL,
PRIMARY KEY (node_id, auth_id),
FOREIGN KEY (node_id) REFERENCES hy2_nodes(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES hy2_users(id) ON DELETE SET NULL
);
CREATE INDEX idx_hy2_users_auth_id ON hy2_users(auth_id);
CREATE INDEX idx_hy2_users_username ON hy2_users(username);
CREATE INDEX idx_hy2_nodes_enabled ON hy2_nodes(enabled);
CREATE INDEX idx_traffic_ledgers_auth_id ON traffic_ledgers(auth_id);
CREATE INDEX idx_traffic_ledgers_created_at ON traffic_ledgers(created_at);
CREATE INDEX idx_node_sync_records_node_id ON node_sync_records(node_id, created_at DESC);
CREATE INDEX idx_auth_audits_created_at ON auth_audits(created_at DESC);
CREATE TABLE user_sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
session_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES hy2_users(id) ON DELETE CASCADE
);
CREATE TABLE app_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE UNIQUE INDEX idx_hy2_users_subscription_token ON hy2_users(subscription_token);
CREATE INDEX idx_user_sessions_user_id ON user_sessions(user_id);
PRAGMA user_version = 4;
+11 -9
View File
@@ -1,4 +1,4 @@
import { test, expect } from "@playwright/test";
import { test, expect } from "./e2e-fixtures";
test("compact management flows, drag persistence, filters and mobile layout", async ({
page,
@@ -22,7 +22,11 @@ test("compact management flows, drag persistence, filters and mobile layout", as
["singapore", "Singapore"],
["frankfurt", "Frankfurt"],
]) {
const create = page.locator(".compact-create-panel details");
const create = page
.locator(".compact-create-panel details")
.filter({
has: page.getByRole("heading", { name: "新增 HY2 节点", exact: true }),
});
if ((await create.getAttribute("open")) === null)
await create.locator("summary").click();
await create.getByLabel("Slug", { exact: true }).fill(slug);
@@ -158,13 +162,11 @@ test("compact management flows, drag persistence, filters and mobile layout", as
"dashboard",
]) {
await page.goto(`/admin/${route}`);
await expect
.poll(() =>
page.evaluate(
() => document.documentElement.scrollWidth <= window.innerWidth,
),
)
.toBe(true);
await expect.poll(() => page.evaluate(() => ({
width: document.documentElement.scrollWidth,
viewport: innerWidth,
overflowing: [...document.querySelectorAll("body *")].filter(el => el.getBoundingClientRect().right > innerWidth && getComputedStyle(el).position !== "absolute").slice(0, 6).map(el => ({ tag: el.tagName, class: el.className, right: el.getBoundingClientRect().right })),
}))).toMatchObject({ width: 390, viewport: 390 });
await page.screenshot({
path: `test-results/${route}-mobile.png`,
fullPage: true,
+121
View File
@@ -0,0 +1,121 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { parse, stringify } from "yaml";
import {
parseCustomProxyYaml,
renderCustomProxyYaml,
extractProxyNameFromYaml,
} from "../../lib/custom-proxy";
import { buildSubscriptionConfig } from "../../lib/subscription";
import { encryptText } from "../../lib/crypto";
import type { Hy2NodeRow, Hy2UserRow } from "../../lib/store";
const proxy = {
name: 'HK "one": #香港',
type: "anytls",
server: "hk.example.com",
port: 443,
password: 'test:p"ass#word',
udp: true,
alpn: ["h2", "http/1.1"],
};
for (const [name, source] of Object.entries({
object: stringify(proxy),
sequence: stringify([proxy]),
wrapper: stringify({ proxies: [proxy] }),
flow: JSON.stringify(proxy),
})) {
test(`accepts ${name} and preserves protocol fields and quoted names on output`, () => {
assert.deepEqual(parseCustomProxyYaml(source), proxy);
assert.equal(extractProxyNameFromYaml(source), proxy.name);
assert.deepEqual(parse(renderCustomProxyYaml(source)), [proxy]);
});
}
for (const value of [
"",
"[]",
"[a, b]",
"- name: [bad",
"name: X\nname: Y\ntype: anytls",
"name: 123\ntype: anytls",
"name: X",
"name: X\ntype: anytls\npassword: *missing",
"proxies:\n - name: X\n type: anytls\n - name: Y\n type: anytls",
]) {
test(`rejects malformed or multiple proxies: ${JSON.stringify(value).slice(0, 40)}`, () =>
assert.throws(() => parseCustomProxyYaml(value)));
}
test("mixed subscriptions preserve order, names, group references, credentials and disabled filtering", () => {
const custom = {
id: 2,
slug: "custom",
node_type: "custom",
name: "Display name",
client_name: "Old fallback",
custom_config_yaml: stringify({ proxies: [proxy] }),
enabled: 1,
} as Hy2NodeRow;
const hy2 = {
id: 1,
slug: "hy2",
node_type: "hy2",
name: "Tokyo",
client_name: 'Tokyo "HY2"',
server_host: "jp.example.com",
server_port: 443,
enabled: 1,
custom_config_yaml: "",
bandwidth_up_mbps: 30,
bandwidth_down_mbps: 30,
} as Hy2NodeRow;
const user = {
username: "member",
auth_secret_encrypted: encryptText('test:abc"def'),
} as Hy2UserRow;
const settings = {
template: "mode: rule",
profileName: 'Profile: "1"',
extraProxyGroups: "",
mainProxyGroupMemberSlugs: ["custom", "hy2"],
proxyGroups: [
{
id: "work",
name: 'Work: "1"',
type: "select" as const,
memberSlugs: ["custom", "disabled", "hy2"],
},
],
};
const result = parse(
buildSubscriptionConfig({
user,
nodes: [hy2, custom, { ...custom, slug: "disabled", enabled: 0 }],
settings,
}),
);
assert.deepEqual(result.proxies[0], proxy);
assert.equal(result.proxies[1].password, 'member:test:abc"def');
assert.equal(result.proxies[1].name, hy2.client_name);
assert.deepEqual(
result["proxy-groups"].map((group: { proxies: string[] }) => group.proxies),
[
[proxy.name, hy2.client_name],
[proxy.name, hy2.client_name],
],
);
assert.equal(result["proxy-groups"][0].name, settings.profileName);
assert.equal(result["proxy-groups"][1].name, settings.proxyGroups[0].name);
assert.equal(result.proxies.length, 2);
// Custom-only subscriptions do not require HY2 credentials.
assert.deepEqual(
parse(
buildSubscriptionConfig({
user: { ...user, auth_secret_encrypted: null },
nodes: [custom],
settings,
}),
).proxies,
[proxy],
);
});
+130
View File
@@ -0,0 +1,130 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test";
import Database from "better-sqlite3";
import { migrateDatabase } from "../../lib/db-migrations";
const remoteSchema = readFileSync(
"tests/fixtures/remote-v4-schema.sql",
"utf8",
);
function remoteDb() {
const db = new Database(":memory:");
db.pragma("foreign_keys = ON");
db.exec(remoteSchema);
return db;
}
function snapshot(db: Database.Database) {
return Object.fromEntries(
(
db
.prepare(
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name",
)
.all() as { name: string }[]
).map(({ name }) => [name, db.prepare(`SELECT * FROM "${name}"`).all()]),
);
}
function seed(db: Database.Database) {
db.exec(`INSERT INTO hy2_users (id,auth_id,username,password_hash,notes,created_at,updated_at,subscription_token,auth_secret_encrypted,traffic_limit_bytes,used_tx_bytes,used_rx_bytes)
VALUES (42,'original-auth','member','test-hash','keep notes','2026-01-01','2026-01-01','test-sub-token','test-encrypted-value',4096,128,256);
INSERT INTO hy2_nodes (id,slug,name,auth_token,traffic_stats_url,traffic_stats_secret,created_at,updated_at,server_host)
VALUES (17,'existing-hy2','Existing HY2','test-auth-token','http://stats.example:9999','test-stats-secret','2026-01-01','2026-01-01','existing.example');
INSERT INTO traffic_ledgers (node_id,user_id,auth_id,tx_bytes,rx_bytes,window_started_at,window_ended_at,created_at) VALUES (17,42,'original-auth',128,256,'2026-01-01','2026-01-01','2026-01-01');
INSERT INTO user_sessions (id,user_id,session_hash,expires_at,created_at,last_seen_at) VALUES ('session',42,'test-session-hash','2030-01-01','2026-01-01','2026-01-01');
INSERT INTO app_settings VALUES ('main_proxy_group_member_slugs_json','["legacy-custom","existing-hy2"]','2026-01-01');
INSERT INTO app_settings VALUES ('proxy_groups_json','[{"id":"group-id","name":"Work","type":"select","memberSlugs":["legacy-custom"]}]','2026-01-01');`);
}
test("fresh database exactly matches the inspected remote v4 column definitions", () => {
const fresh = new Database(":memory:");
const remote = remoteDb();
migrateDatabase(fresh);
assert.equal(fresh.pragma("user_version", { simple: true }), 4);
for (const { name } of remote
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
.all() as { name: string }[]) {
assert.deepEqual(
fresh.pragma(`table_info(${name})`),
remote.pragma(`table_info(${name})`),
);
}
fresh.close();
remote.close();
});
test("remote v4: repeated migration preserves every row, ID, secret, quota, session and group", () => {
const db = remoteDb();
seed(db);
const source = `# legacy source stays byte-for-byte\n- name: "AnyTLS #1"\n type: anytls\n password: "test: password"\n server: hk.example.com\n port: 443\n`;
db.prepare(
`INSERT INTO hy2_nodes (id,slug,name,auth_token,traffic_stats_url,traffic_stats_secret,created_at,updated_at,node_type,custom_config_yaml,client_name) VALUES (18,'legacy-custom','Legacy','test-token','','test-secret','2026-01-01','2026-01-01','custom',?,'Old fallback')`,
).run(source);
const before = snapshot(db);
migrateDatabase(db);
migrateDatabase(db);
assert.deepEqual(snapshot(db), before);
assert.deepEqual(db.pragma("foreign_key_check"), []);
db.close();
});
for (const existing of [
[],
["node_type"],
["custom_config_yaml"],
["node_type", "custom_config_yaml"],
]) {
test(`v3 upgrade with existing columns [${existing.join(", ")}] preserves data`, () => {
const db = remoteDb();
seed(db);
for (const field of ["node_type", "custom_config_yaml"])
if (!existing.includes(field))
db.exec(`ALTER TABLE hy2_nodes DROP COLUMN ${field}`);
db.pragma("user_version = 3");
const before = snapshot(db);
migrateDatabase(db);
migrateDatabase(db);
const after = snapshot(db);
// New fields have compatible defaults; every pre-existing field is unchanged.
for (const row of after.hy2_nodes as Record<string, unknown>[]) {
assert.equal(row.node_type, "hy2");
assert.equal(row.custom_config_yaml, "");
for (const field of ["node_type", "custom_config_yaml"])
if (!existing.includes(field)) delete row[field];
}
assert.deepEqual(after, before);
assert.equal(db.pragma("user_version", { simple: true }), 4);
db.close();
});
}
test("repairs interrupted v4 without duplicate-column errors", () => {
const db = remoteDb();
seed(db);
db.exec("ALTER TABLE hy2_nodes DROP COLUMN custom_config_yaml");
migrateDatabase(db);
assert.equal(
(
db.prepare("SELECT custom_config_yaml FROM hy2_nodes").get() as {
custom_config_yaml: string;
}
).custom_config_yaml,
"",
);
db.close();
});
test("failed migrations roll back all statements and future schemas are not downgraded", () => {
const db = new Database(":memory:");
db.exec("CREATE TABLE hy2_users(id INTEGER)");
assert.throws(() => migrateDatabase(db));
assert.equal(db.pragma("user_version", { simple: true }), 0);
assert.deepEqual(
db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all(),
[{ name: "hy2_users" }],
);
db.pragma("user_version = 5");
assert.throws(() => migrateDatabase(db), /版本/);
assert.equal(db.pragma("user_version", { simple: true }), 5);
db.close();
});
+103
View File
@@ -0,0 +1,103 @@
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { test } from "node:test";
import Database from "better-sqlite3";
// Start with the real deployed schema rather than the new migration output.
test("existing v4 boots and custom nodes stay outside HY2 polling/auth while retaining credentials", async () => {
const dir = mkdtempSync(path.join(tmpdir(), "hy2-custom-store-"));
process.env.DATABASE_PATH = path.join(dir, "remote-v4.db");
process.env.ADMIN_USERNAME = "test-admin";
process.env.ADMIN_PASSWORD = "test-admin-local-only";
process.env.POLLER_ENABLED = "false";
const legacy = new Database(process.env.DATABASE_PATH);
legacy.exec(readFileSync("tests/fixtures/remote-v4-schema.sql", "utf8"));
legacy.exec(`INSERT INTO hy2_nodes (id,slug,name,auth_token,traffic_stats_url,traffic_stats_secret,created_at,updated_at,server_host)
VALUES (91,'existing-hy2','Existing HY2','test-auth','http://stats.example:9999','test-stats','2026-01-01','2026-01-01','hy2.example');`);
const yaml =
'# original comment\n- name: "Remote Custom"\n type: anytls\n password: "test-pass"\n server: custom.example\n port: 443\n';
legacy
.prepare(
`INSERT INTO hy2_nodes (id,slug,name,auth_token,traffic_stats_url,traffic_stats_secret,created_at,updated_at,node_type,custom_config_yaml,client_name)
VALUES (92,'existing-custom','Display','test-original-auth','','test-original-stats','2026-01-01','2026-01-01','custom',?,'Old alias')`,
)
.run(yaml);
legacy.exec(
`INSERT INTO app_settings VALUES ('main_proxy_group_member_slugs_json','["existing-custom","existing-hy2"]','2026-01-01')`,
);
const before = legacy.prepare("SELECT * FROM hy2_nodes ORDER BY id").all();
legacy.close();
const { ensureDb } = await import("../../lib/db");
const store = await import("../../lib/store");
const { verifyNodeToken } = await import("../../lib/hy2");
const db = await ensureDb();
try {
assert.deepEqual(
db.prepare("SELECT * FROM hy2_nodes ORDER BY id").all(),
before,
);
assert.deepEqual(
(await store.getSubscriptionSettings()).mainProxyGroupMemberSlugs,
["existing-custom", "existing-hy2"],
);
assert.deepEqual(
(await store.listEnabledNodesForPolling()).map((node) => node.id),
[91],
);
assert.equal((await store.listSubscriptionNodes()).length, 2);
assert.equal(
(await verifyNodeToken("existing-custom", "test-original-auth")).reason,
"node_not_hy2",
);
assert.equal((await verifyNodeToken("existing-hy2", "test-auth")).ok, true);
const custom = (await store.getNodeBySlug("existing-custom"))!;
assert.equal(store.getNodeProxyName(custom), "Remote Custom");
const input = {
nodeType: "custom" as const,
slug: "existing-custom",
name: "Display",
authToken: "",
hy2Listen: "",
serverHost: "",
serverPort: 443,
clientName: "",
bandwidthUpMbps: 30,
bandwidthDownMbps: 30,
skipCertVerify: false,
sni: null,
customConfigYaml: yaml.replace("Remote Custom", "Updated Custom"),
trafficStatsUrl: "",
trafficStatsListen: ":9999",
trafficStatsSecret: "",
enabled: true,
pollIntervalSeconds: 15,
};
await store.updateNode({ ...input, id: custom.id });
const saved = (await store.getNodeBySlug("existing-custom"))!;
assert.equal(saved.id, 92);
assert.equal(saved.custom_config_yaml, input.customConfigYaml);
assert.equal(saved.auth_token, "test-original-auth");
assert.equal(saved.traffic_stats_secret, "test-original-stats");
assert.equal(store.getNodeProxyName(saved), "Updated Custom");
await assert.rejects(() =>
store.updateNode({
...input,
id: 92,
customConfigYaml: "name: [invalid",
}),
);
assert.deepEqual(await store.getNodeBySlug("existing-custom"), saved);
await store.updateNode({ ...input, id: 92, enabled: false });
assert.deepEqual(
(await store.listSubscriptionNodes()).map((node) => node.id),
[91],
);
} finally {
db.close();
global.__hy2PanelDb = undefined;
global.__hy2PanelDbInit = undefined;
rmSync(dir, { recursive: true, force: true });
}
});